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
6 changes: 3 additions & 3 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ permissions:

jobs:
build-test:
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: ubuntu-latest

services:
postgres:
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
version: 10.12.1

- name: Use Node.js ${{ matrix.node-version }}
uses: buildjet/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
Expand All @@ -76,7 +76,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT

- name: Setup pnpm cache
uses: buildjet/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
Expand Down
12 changes: 2 additions & 10 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ on:

jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

if: ${{ github.event.pull_request.head.repo.fork == false }}
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -33,6 +28,7 @@ jobs:

- name: Run Claude Code Review
id: claude-review
if: ${{ !contains(github.event.pull_request.title, '[WIP]') }}
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
Expand Down Expand Up @@ -71,7 +67,3 @@ jobs:
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"

# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')
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.4.3",
"version": "3.4.4",
"description": "ZenStack",
"packageManager": "pnpm@10.23.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/better-auth",
"version": "3.4.3",
"version": "3.4.4",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion 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.4.3",
"version": "3.4.4",
"type": "module",
"author": {
"name": "ZenStack Team"
Expand Down
92 changes: 89 additions & 3 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { invariant } from '@zenstackhq/common-helpers';
import { type ZModelServices, loadDocument } from '@zenstackhq/language';
import { type Model, isDataSource } from '@zenstackhq/language/ast';
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
import { type Model, type Plugin, isDataSource, type LiteralExpr } from '@zenstackhq/language/ast';
import { type CliPlugin, PrismaSchemaGenerator } from '@zenstackhq/sdk';
import colors from 'colors';
import { createJiti } from 'jiti';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { CliError } from '../cli-error';
import { pathToFileURL } from 'node:url';
import terminalLink from 'terminal-link';
import { z } from 'zod';
import { CliError } from '../cli-error';

export function getSchemaFile(file?: string) {
if (file) {
Expand Down Expand Up @@ -219,6 +222,89 @@ export async function getZenStackPackages(
return result.filter((p) => !!p);
}

export function getPluginProvider(plugin: Plugin) {
const providerField = plugin.fields.find((f) => f.name === 'provider');
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
const provider = (providerField.value as LiteralExpr).value as string;
return provider;
}

export async function loadPluginModule(provider: string, basePath: string) {
if (provider.toLowerCase().endsWith('.zmodel')) {
// provider is a zmodel file, no plugin code module to load
return undefined;
}

let moduleSpec = provider;
if (moduleSpec.startsWith('.')) {
// relative to schema's path
moduleSpec = path.resolve(basePath, moduleSpec);
}

const importAsEsm = async (spec: string) => {
try {
const result = (await import(spec)).default as CliPlugin;
return result;
} catch (err) {
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
}
};

const jiti = createJiti(pathToFileURL(basePath).toString());
const importAsTs = async (spec: string) => {
try {
const result = (await jiti.import(spec, { default: true })) as CliPlugin;
return result;
} catch (err) {
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
}
};

const esmSuffixes = ['.js', '.mjs'];
const tsSuffixes = ['.ts', '.mts'];

if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) {
// try provider as ESM file
if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
return await importAsEsm(pathToFileURL(moduleSpec).toString());
}

// try provider as TS file
if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
return await importAsTs(moduleSpec);
}
}

// try ESM index files in provider directory
for (const suffix of esmSuffixes) {
const indexPath = path.join(moduleSpec, `index${suffix}`);
if (fs.existsSync(indexPath)) {
return await importAsEsm(pathToFileURL(indexPath).toString());
}
}

// try TS index files in provider directory
for (const suffix of tsSuffixes) {
const indexPath = path.join(moduleSpec, `index${suffix}`);
if (fs.existsSync(indexPath)) {
return await importAsTs(indexPath);
}
}

// last resort, try to import as esm directly
try {
const mod = await import(moduleSpec);
// plugin may not export a generator, return undefined in that case
return mod.default as CliPlugin | undefined;
} catch (err) {
const errorCode = (err as NodeJS.ErrnoException)?.code;
if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') {
throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`);
}
throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`);
}
}

const FETCH_CLI_MAX_TIME = 1000;
const CLI_CONFIG_ENDPOINT = 'https://zenstack.dev/config/cli-v3.json';

Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/actions/check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { isPlugin, type Model } from '@zenstackhq/language/ast';
import colors from 'colors';
import { getSchemaFile, loadSchemaDocument } from './action-utils';
import path from 'node:path';
import { getPluginProvider, getSchemaFile, loadPluginModule, loadSchemaDocument } from './action-utils';

type Options = {
schema?: string;
Expand All @@ -12,11 +14,22 @@ export async function run(options: Options) {
const schemaFile = getSchemaFile(options.schema);

try {
await loadSchemaDocument(schemaFile);
const model = await loadSchemaDocument(schemaFile);
await checkPluginResolution(schemaFile, model);
console.log(colors.green('✓ Schema validation completed successfully.'));
} catch (error) {
console.error(colors.red('✗ Schema validation failed.'));
// Re-throw to maintain CLI exit code behavior
throw error;
}
}

async function checkPluginResolution(schemaFile: string, model: Model) {
const plugins = model.declarations.filter(isPlugin);
for (const plugin of plugins) {
const provider = getPluginProvider(plugin);
if (!provider.startsWith('@core/')) {
await loadPluginModule(provider, path.dirname(schemaFile));
}
}
}
82 changes: 4 additions & 78 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import { invariant, singleDebounce } from '@zenstackhq/common-helpers';
import { ZModelLanguageMetaData } from '@zenstackhq/language';
import { isPlugin, LiteralExpr, Plugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast';
import { isPlugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast';
import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils';
import { type CliPlugin } from '@zenstackhq/sdk';
import { watch } from 'chokidar';
import colors from 'colors';
import { createJiti } from 'jiti';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import ora, { type Ora } from 'ora';
import semver from 'semver';
import { CliError } from '../cli-error';
import * as corePlugins from '../plugins';
import {
getOutputPath,
getPluginProvider,
getSchemaFile,
getZenStackPackages,
loadPluginModule,
loadSchemaDocument,
startUsageTipsFetch,
} from './action-utils';
Expand Down Expand Up @@ -258,14 +257,7 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string,
}
}

function getPluginProvider(plugin: Plugin) {
const providerField = plugin.fields.find((f) => f.name === 'provider');
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
const provider = (providerField.value as LiteralExpr).value as string;
return provider;
}

function getPluginOptions(plugin: Plugin): Record<string, unknown> {
function getPluginOptions(plugin: Parameters<typeof getPluginProvider>[0]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const field of plugin.fields) {
if (field.name === 'provider') {
Expand All @@ -281,72 +273,6 @@ function getPluginOptions(plugin: Plugin): Record<string, unknown> {
return result;
}

async function loadPluginModule(provider: string, basePath: string) {
let moduleSpec = provider;
if (moduleSpec.startsWith('.')) {
// relative to schema's path
moduleSpec = path.resolve(basePath, moduleSpec);
}

const importAsEsm = async (spec: string) => {
try {
const result = (await import(spec)).default as CliPlugin;
return result;
} catch (err) {
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
}
};

const jiti = createJiti(pathToFileURL(basePath).toString());
const importAsTs = async (spec: string) => {
try {
const result = (await jiti.import(spec, { default: true })) as CliPlugin;
return result;
} catch (err) {
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
}
};

const esmSuffixes = ['.js', '.mjs'];
const tsSuffixes = ['.ts', '.mts'];

if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) {
// try provider as ESM file
if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
return await importAsEsm(pathToFileURL(moduleSpec).toString());
}

// try provider as TS file
if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
return await importAsTs(moduleSpec);
}
}

// try ESM index files in provider directory
for (const suffix of esmSuffixes) {
const indexPath = path.join(moduleSpec, `index${suffix}`);
if (fs.existsSync(indexPath)) {
return await importAsEsm(pathToFileURL(indexPath).toString());
}
}

// try TS index files in provider directory
for (const suffix of tsSuffixes) {
const indexPath = path.join(moduleSpec, `index${suffix}`);
if (fs.existsSync(indexPath)) {
return await importAsTs(indexPath);
}
}

// last resort, try to import as esm directly
try {
return (await import(moduleSpec)).default as CliPlugin;
} catch {
// plugin may not export a generator so we simply ignore the error here
return undefined;
}
}

async function checkForMismatchedPackages(projectPath: string) {
const packages = await getZenStackPackages(projectPath);
if (!packages.length) {
Expand Down
12 changes: 6 additions & 6 deletions packages/cli/src/actions/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,18 @@ export async function run(options: Options) {

const schemaModule = (await jiti.import(path.join(outputPath, 'schema'))) as any;

// Build omit configuration for computed fields
// Build omit configuration for computed fields and Unsupported fields.
const schema = schemaModule.schema as SchemaDef;
const omit: Record<string, Record<string, boolean>> = {};
for (const [modelName, modelDef] of Object.entries(schema.models)) {
const computedFields: Record<string, boolean> = {};
const omitFields: Record<string, boolean> = {};
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
if (fieldDef.computed === true) {
computedFields[fieldName] = true;
if (fieldDef.computed === true || fieldDef.type === 'Unsupported') {
omitFields[fieldName] = true;
}
}
if (Object.keys(computedFields).length > 0) {
omit[modelName] = computedFields;
if (Object.keys(omitFields).length > 0) {
omit[modelName] = omitFields;
}
}

Expand Down
Loading
Loading