Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +48 to +50
Copy link

Copilot AI Jan 15, 2026

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.

Copilot uses AI. Check for mistakes.
"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": {
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
91 changes: 42 additions & 49 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
jiashengguo marked this conversation as resolved.

type Options = {
schema?: string;
Expand All @@ -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<string>(
(
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<string>(
(
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);

Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, unknown> }[] = [];
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
177 changes: 177 additions & 0 deletions packages/cli/src/actions/proxy.ts
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);
Comment thread
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
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dataSource variable can be undefined if no datasource is found in the schema, but it's used with optional chaining on line 45 and 58 without checking if it exists first. When dataSource is undefined on line 58, the provider will be undefined and cause a runtime error. Add a check to ensure dataSource exists and throw a clear error if it's missing.

Copilot uses AI. Check for mistakes.
const provider = getStringLiteral(dataSource?.fields.find((f) => f.name === 'provider')?.value)!;

const dialect = createDialect(provider, databaseUrl!, outputPath);
Comment thread
jiashengguo marked this conversation as resolved.

const jiti = createJiti(import.meta.url);

Comment thread
jiashengguo marked this conversation as resolved.
const schemaModule = (await jiti.import(path.join(outputPath, 'schema'))) as any;
Comment thread
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`);
}
Comment thread
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);
Comment thread
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
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When resolving SQLite file paths, the code only handles the 'file:' prefix case. However, if the databaseUrl doesn't start with 'file:' but is still a relative path, it won't be resolved relative to outputPath. Consider handling relative paths without the 'file:' prefix as well for consistency.

Suggested change
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 uses AI. Check for mistakes.
}
}
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,
}),
Comment on lines +126 to +130
Copy link

Copilot AI Jan 15, 2026

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 uses AI. Check for mistakes.
});
default:
throw new CliError(`Unsupported database provider: ${provider}`);
}
}

function startServer(client: ClientContract<any, any>, 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({
Comment on lines +144 to +145
Copy link

Copilot AI Jan 15, 2026

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.

Suggested change
'/api/model',
ZenStackMiddleware({
// getClient returns a shared ZenStack client instance; adapt this if you need
// per-request authentication or user-specific clients

Copilot uses AI. Check for mistakes.
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}`);
Copy link

Copilot AI Jan 15, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
console.log(`You can visit ZenStack Studio at: ${colors.blue('https://studio.zenstack.dev')}`);
});

Comment thread
jiashengguo marked this conversation as resolved.
// Graceful shutdown
process.on('SIGTERM', async () => {
server.close(() => {
console.log('\nZenStack proxy server closed');
});
Comment thread
jiashengguo marked this conversation as resolved.

await client.$disconnect();
process.exit(0);
});

process.on('SIGINT', async () => {
server.close(() => {
console.log('\nZenStack proxy server closed');
Comment on lines +171 to +172
Copy link

Copilot AI Jan 15, 2026

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.

Copilot uses AI. Check for mistakes.
});
await client.$disconnect();
process.exit(0);
});
Comment thread
jiashengguo marked this conversation as resolved.
}
Loading