diff --git a/packages/cli/package.json b/packages/cli/package.json index 401c80e24..9bfcc5f95 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,30 +38,37 @@ "dependencies": { "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/language": "workspace:*", + "@zenstackhq/orm": "workspace:*", "@zenstackhq/sdk": "workspace:*", + "@zenstackhq/server": "workspace:*", + "better-sqlite3": "catalog:", "chokidar": "^5.0.0", "colors": "1.4.0", "commander": "^8.3.0", + "cors": "^2.8.5", "execa": "^9.6.0", + "express": "^5.0.0", "jiti": "^2.6.1", "langium": "catalog:", "mixpanel": "^0.18.1", "ora": "^5.4.1", "package-manager-detector": "^1.3.0", + "pg": "catalog:", "prisma": "catalog:", "semver": "^7.7.2", "ts-pattern": "catalog:" }, "devDependencies": { "@types/better-sqlite3": "catalog:", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/pg": "^8.16.0", "@types/semver": "^7.7.0", "@types/tmp": "catalog:", "@zenstackhq/eslint-config": "workspace:*", - "@zenstackhq/orm": "workspace:*", "@zenstackhq/testtools": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*", - "better-sqlite3": "catalog:", "tmp": "catalog:" }, "engines": { diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index d3d0dacf7..d2e0ca2e9 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -144,3 +144,15 @@ export async function requireDataSourceUrl(schemaFile: string) { throw new CliError('The schema\'s "datasource" must have a "url" field to use this command.'); } } + +export function getOutputPath(options: { output?: string }, schemaFile: string) { + if (options.output) { + return options.output; + } + const pkgJsonConfig = getPkgJsonConfig(process.cwd()); + if (pkgJsonConfig.output) { + return pkgJsonConfig.output; + } else { + return path.dirname(schemaFile); + } +} diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 16e3826c7..7ac6db6b2 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -12,7 +12,7 @@ import { watch } from 'chokidar'; import ora, { type Ora } from 'ora'; import { CliError } from '../cli-error'; import * as corePlugins from '../plugins'; -import { getPkgJsonConfig, getSchemaFile, loadSchemaDocument } from './action-utils'; +import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils'; type Options = { schema?: string; @@ -39,15 +39,16 @@ export async function run(options: Options) { const schemaExtensions = ZModelLanguageMetaData.fileExtensions; // Get real models file path (cuz its merged into single document -> we need use cst nodes) - const getRootModelWatchPaths = (model: Model) => new Set( - ( - model.declarations.filter( - (v) => - v.$cstNode?.parent?.element.$type === 'Model' && - !!v.$cstNode.parent.element.$document?.uri?.fsPath, - ) as AbstractDeclaration[] - ).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath), - ); + const getRootModelWatchPaths = (model: Model) => + new Set( + ( + model.declarations.filter( + (v) => + v.$cstNode?.parent?.element.$type === 'Model' && + !!v.$cstNode.parent.element.$document?.uri?.fsPath, + ) as AbstractDeclaration[] + ).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath), + ); const watchedPaths = getRootModelWatchPaths(model); @@ -64,40 +65,44 @@ export async function run(options: Options) { }); // prevent save multiple files and run multiple times - const reGenerateSchema = singleDebounce(async () => { - if (logsEnabled) { - console.log('Got changes, run generation!'); - } + const reGenerateSchema = singleDebounce( + async () => { + if (logsEnabled) { + console.log('Got changes, run generation!'); + } - try { - const newModel = await pureGenerate(options, true); - const allModelsPaths = getRootModelWatchPaths(newModel); - const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at)); - const removeModelPaths = [...watchedPaths].filter((at) => !allModelsPaths.has(at)); + try { + const newModel = await pureGenerate(options, true); + const allModelsPaths = getRootModelWatchPaths(newModel); + const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at)); + const removeModelPaths = [...watchedPaths].filter((at) => !allModelsPaths.has(at)); - if (newModelPaths.length) { - if (logsEnabled) { - const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n'); - console.log(`Added file(s) to watch:\n${logPaths}`); + if (newModelPaths.length) { + if (logsEnabled) { + const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n'); + console.log(`Added file(s) to watch:\n${logPaths}`); + } + + newModelPaths.forEach((at) => watchedPaths.add(at)); + watcher.add(newModelPaths); } - newModelPaths.forEach((at) => watchedPaths.add(at)); - watcher.add(newModelPaths); - } + if (removeModelPaths.length) { + if (logsEnabled) { + const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n'); + console.log(`Removed file(s) from watch:\n${logPaths}`); + } - if (removeModelPaths.length) { - if (logsEnabled) { - const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n'); - console.log(`Removed file(s) from watch:\n${logPaths}`); + removeModelPaths.forEach((at) => watchedPaths.delete(at)); + watcher.unwatch(removeModelPaths); } - - removeModelPaths.forEach((at) => watchedPaths.delete(at)); - watcher.unwatch(removeModelPaths); + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); - } - }, 500, true); + }, + 500, + true, + ); watcher.on('unlink', (pathAt) => { if (logsEnabled) { @@ -148,18 +153,6 @@ Check documentation: https://zenstack.dev/docs/`); return model; } -function getOutputPath(options: Options, schemaFile: string) { - if (options.output) { - return options.output; - } - const pkgJsonConfig = getPkgJsonConfig(process.cwd()); - if (pkgJsonConfig.output) { - return pkgJsonConfig.output; - } else { - return path.dirname(schemaFile); - } -} - async function runPlugins(schemaFile: string, model: Model, outputPath: string, options: Options) { const plugins = model.declarations.filter(isPlugin); const processedPlugins: { cliPlugin: CliPlugin; pluginOptions: Record }[] = []; diff --git a/packages/cli/src/actions/index.ts b/packages/cli/src/actions/index.ts index 88bce15c3..e421cdca0 100644 --- a/packages/cli/src/actions/index.ts +++ b/packages/cli/src/actions/index.ts @@ -6,5 +6,6 @@ import { run as info } from './info'; import { run as init } from './init'; import { run as migrate } from './migrate'; import { run as seed } from './seed'; +import { run as proxy } from './proxy'; -export { check, db, format, generate, info, init, migrate, seed }; +export { check, db, format, generate, info, init, migrate, seed, proxy }; diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts new file mode 100644 index 000000000..45375b05d --- /dev/null +++ b/packages/cli/src/actions/proxy.ts @@ -0,0 +1,177 @@ +import { isDataSource } from '@zenstackhq/language/ast'; +import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils'; +import { CliError } from '../cli-error'; +import { ZModelCodeGenerator } from '@zenstackhq/language'; +import { getStringLiteral } from '@zenstackhq/language/utils'; +import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; +import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres'; +import SQLite from 'better-sqlite3'; +import { Pool } from 'pg'; +import path from 'node:path'; +import { ZenStackClient, type ClientContract } from '@zenstackhq/orm'; +import { RPCApiHandler } from '@zenstackhq/server/api'; +import { ZenStackMiddleware } from '@zenstackhq/server/express'; +import express from 'express'; +import colors from 'colors'; +import { createJiti } from 'jiti'; +import { getVersion } from '../utils/version-utils'; +import cors from 'cors'; + +type Options = { + output?: string; + schema?: string; + port?: number; + logLevel?: string[]; + databaseUrl?: string; +}; + +export async function run(options: Options) { + const schemaFile = getSchemaFile(options.schema); + console.log(colors.gray(`Loading ZModel schema from: ${schemaFile}`)); + + let outputPath = getOutputPath(options, schemaFile); + + // Ensure outputPath is absolute + if (!path.isAbsolute(outputPath)) { + outputPath = path.resolve(process.cwd(), outputPath); + } + + const model = await loadSchemaDocument(schemaFile); + + const dataSource = model.declarations.find(isDataSource); + + let databaseUrl = options.databaseUrl; + + if (!databaseUrl) { + const schemaUrl = dataSource?.fields.find((f) => f.name === 'url')?.value; + + if (!schemaUrl) { + throw new CliError( + `The schema's "datasource" does not have a "url" field, please provide it with -d option.`, + ); + } + const zModelGenerator = new ZModelCodeGenerator(); + const url = zModelGenerator.generate(schemaUrl); + + databaseUrl = evaluateUrl(url); + } + + const provider = getStringLiteral(dataSource?.fields.find((f) => f.name === 'provider')?.value)!; + + const dialect = createDialect(provider, databaseUrl!, outputPath); + + const jiti = createJiti(import.meta.url); + + const schemaModule = (await jiti.import(path.join(outputPath, 'schema'))) as any; + + const allowedLogLevels = ['error', 'query'] as const; + const log = options.logLevel?.filter((level): level is (typeof allowedLogLevels)[number] => + allowedLogLevels.includes(level as any), + ); + + const db = new ZenStackClient(schemaModule.schema, { + dialect: dialect, + log: log && log.length > 0 ? log : undefined, + }); + + // check whether the database is reachable + try { + await db.$connect(); + } catch (err) { + throw new CliError(`Failed to connect to the database: ${err instanceof Error ? err.message : String(err)}`); + } + + startServer(db, schemaModule.schema, options); +} + +function evaluateUrl(value: string): string { + // Create env helper function + const env = (varName: string) => { + const envValue = process.env[varName]; + if (!envValue) { + throw new CliError(`Environment variable ${varName} is not set`); + } + return envValue; + }; + + try { + // Use Function constructor to evaluate the url value + const urlFn = new Function('env', `return ${value}`); + const url = urlFn(env); + return url; + } catch (err) { + if (err instanceof CliError) { + throw err; + } + throw new CliError('Could not evaluate datasource url from schema, you could provide it via -d option.'); + } +} + +function createDialect(provider: string, databaseUrl: string, outputPath: string) { + switch (provider) { + case 'sqlite': { + let resolvedUrl = databaseUrl.trim(); + if (resolvedUrl.startsWith('file:')) { + const filePath = resolvedUrl.substring('file:'.length); + if (!path.isAbsolute(filePath)) { + resolvedUrl = path.join(outputPath, filePath); + } + } + console.log(colors.gray(`Connecting to SQLite database at: ${resolvedUrl}`)); + return new SqliteDialect({ + database: new SQLite(resolvedUrl), + }); + } + case 'postgresql': + console.log(colors.gray(`Connecting to PostgreSQL database at: ${databaseUrl}`)); + return new PostgresDialect({ + pool: new Pool({ + connectionString: databaseUrl, + }), + }); + default: + throw new CliError(`Unsupported database provider: ${provider}`); + } +} + +function startServer(client: ClientContract, schema: any, options: Options) { + const app = express(); + app.use(cors()); + app.use(express.json({ limit: '5mb' })); + app.use(express.urlencoded({ extended: true, limit: '5mb' })); + + app.use( + '/api/model', + ZenStackMiddleware({ + apiHandler: new RPCApiHandler({ schema }), + getClient: () => client, + }), + ); + + app.get('/api/schema', (_req, res: express.Response) => { + res.json({ ...schema, zenstackVersion: getVersion() }); + }); + + const server = app.listen(options.port, () => { + console.log(`ZenStack proxy server is running on port: ${options.port}`); + console.log(`You can visit ZenStack Studio at: ${colors.blue('https://studio.zenstack.dev')}`); + }); + + // Graceful shutdown + process.on('SIGTERM', async () => { + server.close(() => { + console.log('\nZenStack proxy server closed'); + }); + + await client.$disconnect(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + server.close(() => { + console.log('\nZenStack proxy server closed'); + }); + await client.$disconnect(); + process.exit(0); + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0d663044c..836823f06 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -38,6 +38,10 @@ const seedAction = async (options: Parameters[0], args: str await telemetry.trackCommand('db seed', () => actions.seed(options, args)); }; +const proxyAction = async (options: Parameters[0]): Promise => { + await telemetry.trackCommand('proxy', () => actions.proxy(options)); +}; + function createProgram() { const program = new Command('zen') .alias('zenstack') @@ -186,6 +190,18 @@ Arguments following -- are passed to the seed script. E.g.: "zen db seed -- --us .addOption(noVersionCheckOption) .action(formatAction); + program + .command('proxy') + .alias('studio') + .description('Start the ZenStack proxy server') + .addOption(schemaOption) + .addOption(new Option('-p, --port ', 'port to run the proxy server on').default(8008)) + .addOption(new Option('-o, --output ', 'output directory for `zen generate` command')) + .addOption(new Option('-d, --databaseUrl ', 'database connection URL')) + .addOption(new Option('-l, --logLevel ', 'Query log levels (e.g., query, error)')) + .addOption(noVersionCheckOption) + .action(proxyAction); + program.addHelpCommand('help [command]', 'Display help for a command'); program.hook('preAction', async (_thisCommand, actionCommand) => { @@ -221,7 +237,10 @@ async function main() { } } - if (program.args.includes('generate') && (program.args.includes('-w') || program.args.includes('--watch'))) { + if ( + (program.args.includes('generate') && (program.args.includes('-w') || program.args.includes('--watch'))) || + ['proxy', 'studio'].some((cmd) => program.args.includes(cmd)) + ) { // A "hack" way to prevent the process from terminating because we don't want to stop it. return; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b686eb02..5a39d54f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ catalogs: react-dom: specifier: 19.2.0 version: 19.2.0 + sql.js: + specifier: ^1.13.0 + version: 1.13.0 svelte: specifier: 5.45.6 version: 5.45.6 @@ -177,9 +180,18 @@ importers: '@zenstackhq/language': specifier: workspace:* version: link:../language + '@zenstackhq/orm': + specifier: workspace:* + version: link:../orm '@zenstackhq/sdk': specifier: workspace:* version: link:../sdk + '@zenstackhq/server': + specifier: workspace:* + version: link:../server + better-sqlite3: + specifier: 'catalog:' + version: 12.5.0 chokidar: specifier: ^5.0.0 version: 5.0.0 @@ -189,9 +201,15 @@ importers: commander: specifier: ^8.3.0 version: 8.3.0 + cors: + specifier: ^2.8.5 + version: 2.8.5 execa: specifier: ^9.6.0 version: 9.6.0 + express: + specifier: ^5.0.0 + version: 5.1.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -207,6 +225,9 @@ importers: package-manager-detector: specifier: ^1.3.0 version: 1.3.0 + pg: + specifier: 'catalog:' + version: 8.16.3 prisma: specifier: 'catalog:' version: 6.19.0(magicast@0.3.5)(typescript@5.9.3) @@ -220,6 +241,15 @@ importers: '@types/better-sqlite3': specifier: 'catalog:' version: 7.6.13 + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.0 + version: 5.0.3 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 '@types/semver': specifier: ^7.7.0 version: 7.7.0 @@ -229,9 +259,6 @@ importers: '@zenstackhq/eslint-config': specifier: workspace:* version: link:../config/eslint-config - '@zenstackhq/orm': - specifier: workspace:* - version: link:../orm '@zenstackhq/testtools': specifier: workspace:* version: link:../testtools @@ -241,9 +268,6 @@ importers: '@zenstackhq/vitest-config': specifier: workspace:* version: link:../config/vitest-config - better-sqlite3: - specifier: 'catalog:' - version: 12.5.0 tmp: specifier: 'catalog:' version: 0.2.3 @@ -3452,6 +3476,9 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3498,6 +3525,9 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/pluralize@0.0.33': resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} @@ -4510,6 +4540,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -10668,6 +10702,10 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 20.19.24 + '@types/deep-eql@4.0.2': {} '@types/emscripten@1.40.1': {} @@ -10721,6 +10759,12 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 20.19.24 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/pluralize@0.0.33': {} '@types/qs@6.14.0': {} @@ -11912,6 +11956,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc-32@1.2.2: {} crc32-stream@6.0.0: