Skip to content
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
1 change: 0 additions & 1 deletion .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,3 @@ jobs:

${{ steps.changelog.outputs.changelog }}
draft: true
prerelease: true
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-v3",
"version": "3.0.0-alpha.6",
"version": "3.0.0-alpha.7",
"description": "ZenStack",
"packageManager": "[email protected]",
"scripts": {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.0.0-alpha.6",
"version": "3.0.0-alpha.7",
"type": "module",
"author": {
"name": "ZenStack Team"
Expand All @@ -28,9 +28,9 @@
"pack": "pnpm pack"
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/language": "workspace:*",
"@zenstackhq/sdk": "workspace:*",
"@zenstackhq/common-helpers": "workspace:*",
"colors": "1.4.0",
"commander": "^8.3.0",
"langium": "catalog:",
Expand All @@ -43,10 +43,12 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/tmp": "^0.2.6",
"@zenstackhq/eslint-config": "workspace:*",
"@zenstackhq/runtime": "workspace:*",
"@zenstackhq/testtools": "workspace:*",
"@zenstackhq/typescript-config": "workspace:*",
"better-sqlite3": "^11.8.1"
"better-sqlite3": "^11.8.1",
"tmp": "^0.2.3"
}
}
14 changes: 12 additions & 2 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import fs from 'node:fs';
import { CliError } from '../cli-error';
import { loadDocument } from '@zenstackhq/language';
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
import colors from 'colors';
import fs from 'node:fs';
import path from 'node:path';
import { CliError } from '../cli-error';

export function getSchemaFile(file?: string) {
if (file) {
Expand Down Expand Up @@ -41,3 +43,11 @@ export function handleSubProcessError(err: unknown) {
process.exit(1);
}
}

export async function generateTempPrismaSchema(zmodelPath: string) {
const model = await loadSchemaDocument(zmodelPath);
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
const prismaSchemaFile = path.resolve(path.dirname(zmodelPath), '~schema.prisma');
fs.writeFileSync(prismaSchemaFile, prismaSchema);
return prismaSchemaFile;
}
Comment on lines +47 to +53
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Address potential race conditions and improve error handling.

The current implementation has several concerns:

  1. Race condition risk: Using a fixed filename ~schema.prisma could cause conflicts if multiple processes run simultaneously in the same directory.
  2. Synchronous file operations: Using fs.writeFileSync in an async function context is inconsistent and could block the event loop.
  3. Missing error handling: No error handling for file write operations.

Consider this improved implementation:

 export async function generateTempPrismaSchema(zmodelPath: string) {
     const model = await loadSchemaDocument(zmodelPath);
     const prismaSchema = await new PrismaSchemaGenerator(model).generate();
-    const prismaSchemaFile = path.resolve(path.dirname(zmodelPath), '~schema.prisma');
-    fs.writeFileSync(prismaSchemaFile, prismaSchema);
+    const prismaSchemaFile = path.resolve(path.dirname(zmodelPath), `~schema-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.prisma`);
+    await fs.promises.writeFile(prismaSchemaFile, prismaSchema);
     return prismaSchemaFile;
 }

This addresses the race condition with a unique filename and uses async file operations for consistency.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function generateTempPrismaSchema(zmodelPath: string) {
const model = await loadSchemaDocument(zmodelPath);
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
const prismaSchemaFile = path.resolve(path.dirname(zmodelPath), '~schema.prisma');
fs.writeFileSync(prismaSchemaFile, prismaSchema);
return prismaSchemaFile;
}
export async function generateTempPrismaSchema(zmodelPath: string) {
const model = await loadSchemaDocument(zmodelPath);
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
const prismaSchemaFile = path.resolve(
path.dirname(zmodelPath),
`~schema-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.prisma`
);
await fs.promises.writeFile(prismaSchemaFile, prismaSchema);
return prismaSchemaFile;
}
🤖 Prompt for AI Agents
In packages/cli/src/actions/action-utils.ts around lines 47 to 53, the function
generateTempPrismaSchema uses a fixed filename '~schema.prisma' which can cause
race conditions if multiple processes run concurrently in the same directory. It
also uses synchronous file writing which blocks the event loop and lacks error
handling for file operations. To fix this, generate a unique temporary filename
for each call to avoid conflicts, replace fs.writeFileSync with the asynchronous
fs.promises.writeFile to maintain async consistency, and wrap the file writing
in a try-catch block to handle and propagate any errors properly.

53 changes: 27 additions & 26 deletions packages/cli/src/actions/db.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,44 @@
import path from 'node:path';
import fs from 'node:fs';
import { execPackage } from '../utils/exec-utils';
import { getSchemaFile, handleSubProcessError } from './action-utils';
import { run as runGenerate } from './generate';
import { generateTempPrismaSchema, getSchemaFile, handleSubProcessError } from './action-utils';

type CommonOptions = {
type Options = {
schema?: string;
name?: string;
acceptDataLoss?: boolean;
forceReset?: boolean;
};

/**
* CLI action for db related commands
*/
export async function run(command: string, options: CommonOptions) {
const schemaFile = getSchemaFile(options.schema);

// run generate first
await runGenerate({
schema: schemaFile,
silent: true,
});

const prismaSchemaFile = path.join(path.dirname(schemaFile), 'schema.prisma');

export async function run(command: string, options: Options) {
switch (command) {
case 'push':
await runPush(prismaSchemaFile, options);
await runPush(options);
break;
}
}

async function runPush(prismaSchemaFile: string, options: any) {
const cmd = `prisma db push --schema "${prismaSchemaFile}"${
options.acceptDataLoss ? ' --accept-data-loss' : ''
}${options.forceReset ? ' --force-reset' : ''} --skip-generate`;
async function runPush(options: Options) {
// generate a temp prisma schema file
const schemaFile = getSchemaFile(options.schema);
const prismaSchemaFile = await generateTempPrismaSchema(schemaFile);

try {
await execPackage(cmd, {
stdio: 'inherit',
});
} catch (err) {
handleSubProcessError(err);
// run prisma db push
const cmd = `prisma db push --schema "${prismaSchemaFile}"${
options.acceptDataLoss ? ' --accept-data-loss' : ''
}${options.forceReset ? ' --force-reset' : ''} --skip-generate`;
try {
await execPackage(cmd, {
stdio: 'inherit',
});
} catch (err) {
handleSubProcessError(err);
}
} finally {
if (fs.existsSync(prismaSchemaFile)) {
fs.unlinkSync(prismaSchemaFile);
}
}
}
12 changes: 10 additions & 2 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Options = {
schema?: string;
output?: string;
silent?: boolean;
savePrismaSchema?: string | boolean;
};

/**
Expand All @@ -28,8 +29,15 @@ export async function run(options: Options) {
await runPlugins(model, outputPath, tsSchemaFile);

// generate Prisma schema
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
fs.writeFileSync(path.join(outputPath, 'schema.prisma'), prismaSchema);
if (options.savePrismaSchema) {
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
let prismaSchemaFile = path.join(outputPath, 'schema.prisma');
if (typeof options.savePrismaSchema === 'string') {
prismaSchemaFile = path.resolve(outputPath, options.savePrismaSchema);
fs.mkdirSync(path.dirname(prismaSchemaFile), { recursive: true });
}
fs.writeFileSync(prismaSchemaFile, prismaSchema);
}

if (!options.silent) {
console.log(colors.green('Generation completed successfully.'));
Expand Down
75 changes: 44 additions & 31 deletions packages/cli/src/actions/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,70 @@
import path from 'node:path';
import fs from 'node:fs';
import { execPackage } from '../utils/exec-utils';
import { getSchemaFile } from './action-utils';
import { run as runGenerate } from './generate';
import { generateTempPrismaSchema, getSchemaFile } from './action-utils';

type CommonOptions = {
schema?: string;
};

type DevOptions = CommonOptions & {
name?: string;
createOnly?: boolean;
};

type ResetOptions = CommonOptions & {
force?: boolean;
};

type DeployOptions = CommonOptions;

type StatusOptions = CommonOptions;

/**
* CLI action for migration-related commands
*/
export async function run(command: string, options: CommonOptions) {
const schemaFile = getSchemaFile(options.schema);
const prismaSchemaFile = await generateTempPrismaSchema(schemaFile);

// run generate first
await runGenerate({
schema: schemaFile,
silent: true,
});

const prismaSchemaFile = path.join(path.dirname(schemaFile), 'schema.prisma');

switch (command) {
case 'dev':
await runDev(prismaSchemaFile, options);
break;
try {
switch (command) {
case 'dev':
await runDev(prismaSchemaFile, options as DevOptions);
break;

case 'reset':
await runReset(prismaSchemaFile, options as any);
break;
case 'reset':
await runReset(prismaSchemaFile, options as ResetOptions);
break;

case 'deploy':
await runDeploy(prismaSchemaFile, options);
break;
case 'deploy':
await runDeploy(prismaSchemaFile, options as DeployOptions);
break;

case 'status':
await runStatus(prismaSchemaFile, options);
break;
case 'status':
await runStatus(prismaSchemaFile, options as StatusOptions);
break;
}
} finally {
if (fs.existsSync(prismaSchemaFile)) {
fs.unlinkSync(prismaSchemaFile);
}
}
}

async function runDev(prismaSchemaFile: string, _options: unknown) {
async function runDev(prismaSchemaFile: string, options: DevOptions) {
try {
await execPackage(`prisma migrate dev --schema "${prismaSchemaFile}" --skip-generate`, {
stdio: 'inherit',
});
await execPackage(
`prisma migrate dev --schema "${prismaSchemaFile}" --skip-generate${options.name ? ` --name ${options.name}` : ''}${options.createOnly ? ' --create-only' : ''}`,
{
stdio: 'inherit',
},
);
} catch (err) {
handleSubProcessError(err);
}
}

async function runReset(prismaSchemaFile: string, options: { force: boolean }) {
async function runReset(prismaSchemaFile: string, options: ResetOptions) {
try {
await execPackage(`prisma migrate reset --schema "${prismaSchemaFile}"${options.force ? ' --force' : ''}`, {
stdio: 'inherit',
Expand All @@ -61,7 +74,7 @@ async function runReset(prismaSchemaFile: string, options: { force: boolean }) {
}
}

async function runDeploy(prismaSchemaFile: string, _options: unknown) {
async function runDeploy(prismaSchemaFile: string, _options: DeployOptions) {
try {
await execPackage(`prisma migrate deploy --schema "${prismaSchemaFile}"`, {
stdio: 'inherit',
Expand All @@ -71,7 +84,7 @@ async function runDeploy(prismaSchemaFile: string, _options: unknown) {
}
}

async function runStatus(prismaSchemaFile: string, _options: unknown) {
async function runStatus(prismaSchemaFile: string, _options: StatusOptions) {
try {
await execPackage(`prisma migrate status --schema "${prismaSchemaFile}"`, {
stdio: 'inherit',
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export function createProgram() {
.command('generate')
.description('Run code generation.')
.addOption(schemaOption)
.addOption(new Option('--silent', 'do not print any output'))
.addOption(
new Option(
'--save-prisma-schema [path]',
'save a Prisma schema file, by default into the output directory',
),
)
.addOption(new Option('-o, --output <path>', 'default output directory for core plugins'))
.action(generateAction);

Expand Down
18 changes: 18 additions & 0 deletions packages/cli/test/db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProject, runCli } from './utils';

const model = `
model User {
id String @id @default(cuid())
}
`;

describe('CLI db commands test', () => {
it('should generate a database with db push', () => {
const workDir = createProject(model);
runCli('db push', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true);
});
});
44 changes: 44 additions & 0 deletions packages/cli/test/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProject, runCli } from './utils';

const model = `
model User {
id String @id @default(cuid())
}
`;

describe('CLI generate command test', () => {
it('should generate a TypeScript schema', () => {
const workDir = createProject(model);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(false);
});

it('should respect custom output directory', () => {
const workDir = createProject(model);
runCli('generate --output ./zen', workDir);
expect(fs.existsSync(path.join(workDir, 'zen/schema.ts'))).toBe(true);
});

it('should respect custom schema location', () => {
const workDir = createProject(model);
fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'zenstack/foo.zmodel'));
runCli('generate --schema ./zenstack/foo.zmodel', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('should respect save prisma schema option', () => {
const workDir = createProject(model);
runCli('generate --save-prisma-schema', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true);
});

it('should respect save prisma schema custom path option', () => {
const workDir = createProject(model);
runCli('generate --save-prisma-schema "../prisma/schema.prisma"', workDir);
expect(fs.existsSync(path.join(workDir, 'prisma/schema.prisma'))).toBe(true);
});
});
13 changes: 13 additions & 0 deletions packages/cli/test/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';
import tmp from 'tmp';
import { describe, expect, it } from 'vitest';
import { runCli } from './utils';

describe('Cli init command tests', () => {
it('should create a new project', () => {
const { name: workDir } = tmp.dirSync({ unsafeCleanup: true });
runCli('init', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.zmodel'))).toBe(true);
});
});
Loading