-
-
Notifications
You must be signed in to change notification settings - Fork 17
feat(cli): ZenStack proxy #597
Changes from all commits
9be59d7
e423716
7f3d8ff
311af3f
832b84e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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); | ||||||||||||||||||||||||||||||
|
jiashengguo marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+58
|
||||||||||||||||||||||||||||||
| const provider = getStringLiteral(dataSource?.fields.find((f) => f.name === 'provider')?.value)!; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const dialect = createDialect(provider, databaseUrl!, outputPath); | ||||||||||||||||||||||||||||||
|
jiashengguo marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const jiti = createJiti(import.meta.url); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
jiashengguo marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| const schemaModule = (await jiti.import(path.join(outputPath, 'schema'))) as any; | ||||||||||||||||||||||||||||||
|
jiashengguo marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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`); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
jiashengguo marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| return envValue; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| // Use Function constructor to evaluate the url value | ||||||||||||||||||||||||||||||
| const urlFn = new Function('env', `return ${value}`); | ||||||||||||||||||||||||||||||
| const url = urlFn(env); | ||||||||||||||||||||||||||||||
|
jiashengguo marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||
|
Comment on lines
+116
to
+117
|
||||||||||||||||||||||||||||||
| if (!path.isAbsolute(filePath)) { | |
| resolvedUrl = path.join(outputPath, filePath); | |
| resolvedUrl = path.join(outputPath, filePath); | |
| } else { | |
| resolvedUrl = filePath; | |
| } | |
| } else if ( | |
| // treat non-URL, non-special values as file paths and resolve relative to outputPath | |
| !path.isAbsolute(resolvedUrl) && | |
| !resolvedUrl.includes('://') && | |
| resolvedUrl !== ':memory:' | |
| ) { | |
| resolvedUrl = path.join(outputPath, resolvedUrl); |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For PostgreSQL connections, the Pool is created but never explicitly closed during shutdown. While client.$disconnect() might handle this, it's better to explicitly close the pool to ensure proper cleanup of database resources. Consider storing the pool reference and calling pool.end() in the shutdown handlers.
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment mentions 'getSessionUser' but the actual implementation uses 'getClient'. This outdated comment should be updated or removed to avoid confusion, as there is no user session management in this implementation.
| '/api/model', | |
| ZenStackMiddleware({ | |
| // getClient returns a shared ZenStack client instance; adapt this if you need | |
| // per-request authentication or user-specific clients |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The server.listen() call doesn't handle potential errors such as port already in use or permission denied. Add error handling to provide clear feedback when the server fails to start, for example by listening to the 'error' event on the server.
| console.log(`ZenStack proxy server is running on port: ${options.port}`); | |
| server.on('error', (err: NodeJS.ErrnoException) => { | |
| if (err.code === 'EADDRINUSE') { | |
| console.error(`Failed to start ZenStack proxy server: port ${options.port} is already in use.`); | |
| } else if (err.code === 'EACCES') { | |
| console.error( | |
| `Failed to start ZenStack proxy server: insufficient permissions to listen on port ${options.port}.`, | |
| ); | |
| } else { | |
| console.error('Failed to start ZenStack proxy server:', err); | |
| } | |
| process.exit(1); | |
| }); |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The $disconnect call is made asynchronously but process.exit(0) is called immediately after without awaiting the disconnection. This could lead to database connections not being properly closed. Add await before client.$disconnect() to ensure graceful cleanup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cors package is added as a dependency in package.json and the type definitions are included, but cors is never imported or used in the code. If CORS functionality is needed for the Express server, it should be imported and applied using app.use(cors()). If not needed, the dependency should be removed.