diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index b03091d5..8fc0495b 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -6,6 +6,10 @@ on: - main - dev +env: + TELEMETRY_TRACKING_TOKEN: ${{ secrets.TELEMETRY_TRACKING_TOKEN }} + DO_NOT_TRACK: '1' + permissions: contents: read diff --git a/README.md b/README.md index 1d51b715..f134a133 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,11 @@ ZenStack is a TypeScript database toolkit for developing full-stack or backend N - A modern schema-first ORM that's compatible with [Prisma](https://github.com/prisma/prisma)'s schema and API - Versatile data access APIs: high-level (Prisma-style) ORM queries + low-level ([Kysely](https://github.com/kysely-org/kysely)) query builder -- Built-in access control and data validation +- Built-in access control and data validation (coming soon) - Advanced data modeling patterns like [polymorphism](https://zenstack.dev/blog/polymorphism) - Designed for extensibility and flexibility: plugins, life-cycle hooks, etc. -- Automatic CRUD web APIs with adapters for popular frameworks -- Automatic [TanStack Query](https://github.com/TanStack/query) hooks for easy CRUD from the frontend +- Automatic CRUD web APIs with adapters for popular frameworks (coming soon) +- Automatic [TanStack Query](https://github.com/TanStack/query) hooks for easy CRUD from the frontend (coming soon) # What's new with V3 @@ -47,9 +47,9 @@ Even without using advanced features, ZenStack offers the following benefits as 2. More TypeScript type inference, less code generation. 3. Fully-typed query-builder API as a better escape hatch compared to Prisma's [raw queries](https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/raw-queries) or [typed SQL](https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql). -> Although ZenStack v3's runtime doesn't depend on Prisma anymore (specifically, `@prisma/client`), it still relies on Prisma to handle database migration. See [database migration](#database-migration) for more details. +> Although ZenStack v3's runtime doesn't depend on Prisma anymore (specifically, `@prisma/client`), it still relies on Prisma to handle database migration. See [database migration](https://zenstack.dev/docs/3.x/orm/migration) for more details. -# Get started +# Quick start > You can also check the [blog sample](./samples/blog) for a complete example. @@ -82,331 +82,6 @@ npm install @zenstackhq/runtime@next Then create a `zenstack` folder and a `schema.zmodel` file in it. -## Writing ZModel schema +# Documentation -ZenStack uses a DSL named ZModel to model different aspects of database: - -- Tables and fields -- Validation rules (coming soon) -- Access control policies (coming soon) -- ... - -ZModel is a super set of [Prisma Schema Language](https://www.prisma.io/docs/orm/prisma-schema/overview), i.e., every valid Prisma schema is a valid ZModel. - -## Installing a database driver - -ZenStack doesn't bundle any database drivers. You need to install by yourself based on the database provider you use. - -> The project scaffolded by `npm create zenstack` is pre-configured to use SQLite. You only need to follow instructions here if you want to change it. - -For SQLite: - -```bash -npm install better-sqlite3 -npm install -D @types/better-sqlite3 -``` - -For Postgres: - -```bash -npm install pg -npm install -D @types/pg -``` - -## Pushing schema to the database - -Run the following command to sync schema to the database for local development: - -```bash -npx zen db push -``` - -> Under the hood, the command uses `prisma db push` to do the job. - -See [database migration](#database-migration) for how to use migration to manage schema changes for production. - -## Compiling ZModel schema - -ZModel needs to be compiled to TypeScript before being used to create a database db. Simply run the following command: - -```bash -npx zen generate -``` - -A `schema.ts` file will be created inside the `zenstack` folder. The file should be included as part of your source tree for compilation/bundling. You may choose to include or ignore it in source control (and generate on the fly during build). Just remember to rerun the "generate" command whenever you make changes to the ZModel schema. - -## Creating ZenStack client - -Now you can use the compiled TypeScript schema to instantiate a database db. - -### SQLite - -```ts -import { ZenStackClient } from '@zenstackhq/runtime'; -import { schema } from './zenstack/schema'; -import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; - -const db = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite('./dev.db') }), -}); -``` - -### Postgres - -```ts -import { ZenStackClient } from '@zenstackhq/runtime'; -import { schema } from './zenstack/schema'; -import { PostgresDialect } from 'kysely'; -import { Pool } from 'pg'; - -const db = new ZenStackClient(schema, { - dialect: new PostgresDialect({ - pool: new Pool({ connectionString: process.env.DATABASE_URL }), - }), -}); -``` - -## Using `ZenStackClient` - -### ORM API - -`ZenStackClient` offers the full set of CRUD APIs that `PrismaClient` has - `findMany`, `create`, `aggregate`, etc. See [prisma documentation](https://www.prisma.io/docs/orm/prisma-client/queries) for detailed guide. - -A few quick examples: - -```ts -const user = await db.user.create({ - data: { - name: 'Alex', - email: 'alex@zenstack.dev', - posts: { create: { title: 'Hello world' } }, - }, -}); - -const userWithPosts = await db.user.findUnique({ - where: { id: user.id }, - include: { posts: true }, -}); - -const groupedPosts = await db.post.groupBy({ - by: 'published', - _count: true, -}); -``` - -Under the hood, all ORM queries are transformed into Kysely queries for execution. - -### Query builder API - -ZenStack uses Kysely to handle database operations, and it also directly exposes Kysely's query builder. You can use it when your use case outgrows the ORM API's capabilities. The query builder API is fully typed, and its types are directly inferred from `schema.ts` so no extra set up is needed. - -Please check [Kysely documentation](https://kysely.dev/docs/intro) for more details. Here're a few quick examples: - -```ts -await db.$qb - .selectFrom('User') - .leftJoin('Post', 'Post.authorId', 'User.id') - .select(['User.id', 'User.email', 'Post.title']) - .execute(); -``` - -Query builder can also be "blended" into ORM API calls as a local escape hatch for building complex filter conditions. It allows for greater flexibility without forcing you to entirely resort to the query builder API. - -```ts -await db.user.findMany({ - where: { - age: { gt: 18 }, - // "eb" is a Kysely expression builder - $expr: (eb) => eb('email', 'like', '%@zenstack.dev'), - }, -}); -``` - -It provides a good solution to the long standing `whereRaw` [Prisma feature request](https://github.com/prisma/prisma/issues/5560). We may make similar extensions to the `select` and `orderBy` clauses in the future. - -### Computed fields - -ZenStack v3 allows you to define database-evaluated computed fields with the following two steps: - -1. Declare it in ZModel - - ```prisma - model User { - ... - /// number of posts owned by the user - postCount Int @computed - } - ``` - -2. Provide its implementation using query builder when constructing `ZenStackClient` - - ```ts - const db = new ZenStackClient(schema, { - ... - computedFields: { - User: { - postCount: (eb) => - eb - .selectFrom('Post') - .whereRef('Post.authorId', '=', 'id') - .select(({ fn }) => - fn.countAll().as('postCount') - ), - }, - }, - }); - ``` - -You can then use the computed field anywhere a regular field can be used, for field selection, filtering, sorting, etc. The field is fully evaluated at the database side so performance will be optimal. - -### Polymorphic models - -_Coming soon..._ - -### Access policies - -_Coming soon..._ - -### Validation rules - -_Coming soon..._ - -### Custom procedures - -_Coming soon..._ - -### Runtime plugins - -V3 introduces a new runtime plugin mechanism that allows you to tap into the ORM's query execution in various ways. A plugin implements the [RuntimePlugin](./packages/runtime/src/client/plugin.ts#L121) interface, and can be installed with the `ZenStackdb.$use` API. - -You can use a plugin to achieve the following goals: - -#### 1. ORM query interception - -ORM query interception allows you to intercept the high-level ORM API calls. The interceptor's configuration is compatible with Prisma's [query client extension](https://www.prisma.io/docs/orm/prisma-client/client-extensions/query). - -```ts -db.$use({ - id: 'cost-logger', - onQuery: async ({ model, operation, args, proceed }) => { - const start = Date.now(); - const result = await proceed(args); - console.log(`[cost] ${model} ${operation} took ${Date.now() - start}ms`); - return result; - }, -}); -``` - -Usually a plugin would call the `proceed` callback to trigger the execution of the original query, but you can choose to completely override the query behavior with custom logic. - -#### 2. Kysely query interception - -Kysely query interception allows you to intercept the low-level query builder API calls. Since ORM queries are transformed into Kysely queries before execution, they are automatically captured as well. - -Kysely query interception works against the low-level Kysely `OperationNode` structures. It's harder to implement but can guarantee intercepting all CRUD operations. - -```ts -db.$use({ - id: 'insert-interception-plugin', - onKyselyQuery({query, proceed}) { - if (query.kind === 'InsertQueryNode') { - query = sanitizeInsertData(query); - } - return proceed(query); - }, -}); - -function sanitizeInsertData(query: InsertQueryNode) { - ... -} -``` - -#### 3. Entity mutation interception - -Another popular interception use case is, instead of intercepting calls, "listening on" entity changes. - -```ts -db.$use({ - id: 'mutation-hook-plugin', - onEntityMutation: { - beforeEntityMutation({ model, action }) { - console.log(`Before ${model} ${action}`); - }, - - afterEntityMutation({ model, action }) { - console.log(`After ${model} ${action}`); - }, - }, -}); -``` - -You can provide an extra `mutationInterceptionFilter` to control what to intercept, and opt in for loading the affected entities before and/or after the mutation. - -```ts -db.$use({ - id: 'mutation-hook-plugin', - onEntityMutation: { - mutationInterceptionFilter: ({ model }) => { - return { - intercept: model === 'User', - // load entities affected before the mutation (defaults to false) - loadBeforeMutationEntities: true, - // load entities affected after the mutation (defaults to false) - loadAfterMutationEntities: true, - }; - }, - - beforeEntityMutation({ model, action, entities }) { - console.log(`Before ${model} ${action}: ${entities}`); - }, - - afterEntityMutation({ model, action, afterMutationEntities }) { - console.log(`After ${model} ${action}: ${afterMutationEntities}`); - }, - }, -}); -``` - -# Other guides - -## Database migration - -ZenStack v3 delegates database schema migration to Prisma. The CLI provides Prisma CLI wrappers for managing migrations. - -- Sync schema to dev database and create a migration record: - - ```bash - npx zen migrate dev - ``` - -- Deploy new migrations: - - ```bash - npx zen migrate deploy - ``` - -- Reset dev database - - ```bash - npx zen migrate reset - ``` - -See [Prisma Migrate](https://www.prisma.io/docs/orm/prisma-migrate) documentation for more details. - -## Migrating Prisma projects - -1. Install "@zenstackhq/cli@next" and "@zenstackhq/runtime@next" packages -1. Remove "@prisma/client" dependency -1. Install "better-sqlite3" or "pg" based on database type -1. Move "schema.prisma" to "zenstack" folder and rename it to "schema.zmodel" -1. Run `npx zen generate` -1. Replace `new PrismaClient()` with `new ZenStackClient(schema, { ... })` - -# Limitations - -1. Only SQLite (better-sqlite3) and Postgres (pg) database providers are supported for now. -1. Prisma client extensions are not supported. -1. Prisma custom generators are not supported (may add support in the future). -1. [Filtering on JSON fields](https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/working-with-json-fields#filter-on-a-json-field-advanced) is not supported yet. -1. Raw SQL query APIs (`$queryRaw`, `$executeRaw`) are not supported. +Please visit the [doc site](https://zenstack.dev/docs/3.x/) for detailed documentation. diff --git a/TODO.md b/TODO.md index 35edf349..8ccc6729 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,7 @@ - [x] init - [x] validate - [ ] format + - [ ] repl - [x] plugin mechanism - [x] built-in plugins - [x] typescript @@ -82,7 +83,6 @@ - [x] Error system - [x] Custom table name - [x] Custom field name - - [ ] Strict undefined checks - [ ] DbNull vs JsonNull - [ ] Migrate to tsdown - [ ] Benchmark diff --git a/package.json b/package.json index ba555613..38874f7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 84fc7e70..89840e22 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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.33", + "version": "3.0.0-beta.1", "type": "module", "author": { "name": "ZenStack Team" @@ -35,8 +35,10 @@ "colors": "1.4.0", "commander": "^8.3.0", "langium": "catalog:", + "mixpanel": "^0.18.1", "ora": "^5.4.1", "package-manager-detector": "^1.3.0", + "semver": "^7.7.2", "ts-pattern": "catalog:" }, "peerDependencies": { @@ -44,6 +46,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/semver": "^7.7.0", "@types/tmp": "catalog:", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/runtime": "workspace:*", diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index f0041c3d..e80c219d 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -4,7 +4,7 @@ import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils'; import { type CliPlugin } from '@zenstackhq/sdk'; import colors from 'colors'; import path from 'node:path'; -import ora from 'ora'; +import ora, { type Ora } from 'ora'; import { CliError } from '../cli-error'; import * as corePlugins from '../plugins'; import { getPkgJsonConfig, getSchemaFile, loadSchemaDocument } from './action-utils'; @@ -12,6 +12,7 @@ import { getPkgJsonConfig, getSchemaFile, loadSchemaDocument } from './action-ut type Options = { schema?: string; output?: string; + silent: boolean; }; /** @@ -25,10 +26,11 @@ export async function run(options: Options) { const model = await loadSchemaDocument(schemaFile); const outputPath = getOutputPath(options, schemaFile); - await runPlugins(schemaFile, model, outputPath); + await runPlugins(schemaFile, model, outputPath, options); - console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.\n`)); - console.log(`You can now create a ZenStack client with it. + if (!options.silent) { + console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.\n`)); + console.log(`You can now create a ZenStack client with it. \`\`\`ts import { ZenStackClient } from '@zenstackhq/runtime'; @@ -37,7 +39,10 @@ import { schema } from '${outputPath}/schema'; const client = new ZenStackClient(schema, { dialect: { ... } }); -\`\`\``); +\`\`\` + +Check documentation: https://zenstack.dev/docs/3.x`); + } } function getOutputPath(options: Options, schemaFile: string) { @@ -52,7 +57,7 @@ function getOutputPath(options: Options, schemaFile: string) { } } -async function runPlugins(schemaFile: string, model: Model, outputPath: string) { +async function runPlugins(schemaFile: string, model: Model, outputPath: string, options: Options) { const plugins = model.declarations.filter(isPlugin); const processedPlugins: { cliPlugin: CliPlugin; pluginOptions: Record }[] = []; @@ -95,7 +100,11 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string) ); // run plugin generator - const spinner = ora(cliPlugin.statusText ?? `Running plugin ${cliPlugin.name}`).start(); + let spinner: Ora | undefined; + + if (!options.silent) { + spinner = ora(cliPlugin.statusText ?? `Running plugin ${cliPlugin.name}`).start(); + } try { await cliPlugin.generate({ schemaFile, @@ -103,9 +112,9 @@ async function runPlugins(schemaFile: string, model: Model, outputPath: string) defaultOutputPath: outputPath, pluginOptions, }); - spinner.succeed(); + spinner?.succeed(); } catch (err) { - spinner.fail(); + spinner?.fail(); console.error(err); } } diff --git a/packages/cli/src/actions/info.ts b/packages/cli/src/actions/info.ts index 731bdaf8..bbea51eb 100644 --- a/packages/cli/src/actions/info.ts +++ b/packages/cli/src/actions/info.ts @@ -57,6 +57,9 @@ async function getZenStackPackages(projectPath: string): Promise !!p); } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 00000000..586537af --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,2 @@ +// replaced at build time +export const TELEMETRY_TRACKING_TOKEN = ''; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7b17a37e..f55fe6b4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,34 +3,35 @@ import colors from 'colors'; import { Command, CommanderError, Option } from 'commander'; import * as actions from './actions'; import { CliError } from './cli-error'; -import { getVersion } from './utils/version-utils'; +import { telemetry } from './telemetry'; +import { checkNewVersion, getVersion } from './utils/version-utils'; const generateAction = async (options: Parameters[0]): Promise => { - await actions.generate(options); + await telemetry.trackCommand('generate', () => actions.generate(options)); }; -const migrateAction = async (command: string, options: any): Promise => { - await actions.migrate(command, options); +const migrateAction = async (subCommand: string, options: any): Promise => { + await telemetry.trackCommand(`migrate ${subCommand}`, () => actions.migrate(subCommand, options)); }; -const dbAction = async (command: string, options: any): Promise => { - await actions.db(command, options); +const dbAction = async (subCommand: string, options: any): Promise => { + await telemetry.trackCommand(`db ${subCommand}`, () => actions.db(subCommand, options)); }; const infoAction = async (projectPath: string): Promise => { - await actions.info(projectPath); + await telemetry.trackCommand('info', () => actions.info(projectPath)); }; const initAction = async (projectPath: string): Promise => { - await actions.init(projectPath); + await telemetry.trackCommand('init', () => actions.init(projectPath)); }; const checkAction = async (options: Parameters[0]): Promise => { - await actions.check(options); + await telemetry.trackCommand('check', () => actions.check(options)); }; -export function createProgram() { - const program = new Command('zenstack'); +function createProgram() { + const program = new Command('zen'); program.version(getVersion()!, '-v --version', 'display CLI version'); @@ -40,7 +41,7 @@ export function createProgram() { .description( `${colors.bold.blue( 'ΞΆ', - )} ZenStack is the data layer for modern TypeScript apps.\n\nDocumentation: https://zenstack.dev.`, + )} ZenStack is the data layer for modern TypeScript apps.\n\nDocumentation: https://zenstack.dev/docs/3.x`, ) .showHelpAfterError() .showSuggestionAfterError(); @@ -50,11 +51,15 @@ export function createProgram() { `schema file (with extension ${schemaExtensions}). Defaults to "zenstack/schema.zmodel" unless specified in package.json.`, ); + const noVersionCheckOption = new Option('--no-version-check', 'do not check for new version'); + program .command('generate') .description('Run code generation plugins.') .addOption(schemaOption) + .addOption(noVersionCheckOption) .addOption(new Option('-o, --output ', 'default output directory for code generation')) + .addOption(new Option('--silent', 'suppress all output except errors').default(false)) .action(generateAction); const migrateCommand = program.command('migrate').description('Run database schema migration related tasks.'); @@ -63,6 +68,7 @@ export function createProgram() { migrateCommand .command('dev') .addOption(schemaOption) + .addOption(noVersionCheckOption) .addOption(new Option('-n, --name ', 'migration name')) .addOption(new Option('--create-only', 'only create migration, do not apply')) .addOption(migrationsOption) @@ -74,12 +80,14 @@ export function createProgram() { .addOption(schemaOption) .addOption(new Option('--force', 'skip the confirmation prompt')) .addOption(migrationsOption) + .addOption(noVersionCheckOption) .description('Reset your database and apply all migrations, all data will be lost.') .action((options) => migrateAction('reset', options)); migrateCommand .command('deploy') .addOption(schemaOption) + .addOption(noVersionCheckOption) .addOption(migrationsOption) .description('Deploy your pending migrations to your production/staging database.') .action((options) => migrateAction('deploy', options)); @@ -87,6 +95,7 @@ export function createProgram() { migrateCommand .command('status') .addOption(schemaOption) + .addOption(noVersionCheckOption) .addOption(migrationsOption) .description('Check the status of your database migrations.') .action((options) => migrateAction('status', options)); @@ -94,6 +103,7 @@ export function createProgram() { migrateCommand .command('resolve') .addOption(schemaOption) + .addOption(noVersionCheckOption) .addOption(migrationsOption) .addOption(new Option('--applied ', 'record a specific migration as applied')) .addOption(new Option('--rolled-back ', 'record a specific migration as rolled back')) @@ -106,6 +116,7 @@ export function createProgram() { .command('push') .description('Push the state from your schema to your database.') .addOption(schemaOption) + .addOption(noVersionCheckOption) .addOption(new Option('--accept-data-loss', 'ignore data loss warnings')) .addOption(new Option('--force-reset', 'force a reset of the database before push')) .action((options) => dbAction('push', options)); @@ -114,35 +125,64 @@ export function createProgram() { .command('info') .description('Get information of installed ZenStack packages.') .argument('[path]', 'project path', '.') + .addOption(noVersionCheckOption) .action(infoAction); program .command('init') .description('Initialize an existing project for ZenStack.') .argument('[path]', 'project path', '.') + .addOption(noVersionCheckOption) .action(initAction); program .command('check') .description('Check a ZModel schema for syntax or semantic errors.') .addOption(schemaOption) + .addOption(noVersionCheckOption) .action(checkAction); + program.hook('preAction', async (_thisCommand, actionCommand) => { + if (actionCommand.getOptionValue('versionCheck') !== false) { + await checkNewVersion(); + } + }); + return program; } -const program = createProgram(); +async function main() { + let exitCode = 0; + + const program = createProgram(); + program.exitOverride(); + + try { + await telemetry.trackCli(async () => { + await program.parseAsync(); + }); + } catch (e) { + if (e instanceof CommanderError) { + // ignore + exitCode = e.exitCode; + } else if (e instanceof CliError) { + // log + console.error(colors.red(e.message)); + exitCode = 1; + } else { + console.error(colors.red(`Unhandled error: ${e}`)); + exitCode = 1; + } + } -program.parseAsync().catch((err) => { - if (err instanceof CliError) { - console.error(colors.red(err.message)); - process.exit(1); - } else if (err instanceof CommanderError) { - // errors are already reported, just exit - process.exit(err.exitCode); + if (telemetry.isTracking) { + // give telemetry a chance to send events before exit + setTimeout(() => { + process.exit(exitCode); + }, 200); } else { - console.error(colors.red('An unexpected error occurred:')); - console.error(err); - process.exit(1); + process.exit(exitCode); } -}); +} + +main(); diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts new file mode 100644 index 00000000..a078f62d --- /dev/null +++ b/packages/cli/src/telemetry.ts @@ -0,0 +1,139 @@ +import { init, type Mixpanel } from 'mixpanel'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import * as os from 'os'; +import { TELEMETRY_TRACKING_TOKEN } from './constants'; +import { isInCi } from './utils/is-ci'; +import { isInContainer } from './utils/is-container'; +import isDocker from './utils/is-docker'; +import { isWsl } from './utils/is-wsl'; +import { getMachineId } from './utils/machine-id-utils'; +import { getVersion } from './utils/version-utils'; + +/** + * Telemetry events + */ +export type TelemetryEvents = + | 'cli:start' + | 'cli:complete' + | 'cli:error' + | 'cli:command:start' + | 'cli:command:complete' + | 'cli:command:error' + | 'cli:plugin:start' + | 'cli:plugin:complete' + | 'cli:plugin:error'; + +/** + * Utility class for sending telemetry + */ +export class Telemetry { + private readonly mixpanel: Mixpanel | undefined; + private readonly hostId = getMachineId(); + private readonly sessionid = randomUUID(); + private readonly _os_type = os.type(); + private readonly _os_release = os.release(); + private readonly _os_arch = os.arch(); + private readonly _os_version = os.version(); + private readonly _os_platform = os.platform(); + private readonly version = getVersion(); + private readonly prismaVersion = this.getPrismaVersion(); + private readonly isDocker = isDocker(); + private readonly isWsl = isWsl(); + private readonly isContainer = isInContainer(); + private readonly isCi = isInCi; + + constructor() { + if (process.env['DO_NOT_TRACK'] !== '1' && TELEMETRY_TRACKING_TOKEN) { + this.mixpanel = init(TELEMETRY_TRACKING_TOKEN, { + geolocate: true, + }); + } + } + + get isTracking() { + return !!this.mixpanel; + } + + track(event: TelemetryEvents, properties: Record = {}) { + if (this.mixpanel) { + const payload = { + distinct_id: this.hostId, + session: this.sessionid, + time: new Date(), + $os: this._os_type, + osType: this._os_type, + osRelease: this._os_release, + osPlatform: this._os_platform, + osArch: this._os_arch, + osVersion: this._os_version, + nodeVersion: process.version, + version: this.version, + prismaVersion: this.prismaVersion, + isDocker: this.isDocker, + isWsl: this.isWsl, + isContainer: this.isContainer, + isCi: this.isCi, + ...properties, + }; + this.mixpanel.track(event, payload); + } + } + + trackError(err: Error) { + this.track('cli:error', { + message: err.message, + stack: err.stack, + }); + } + + async trackSpan( + startEvent: TelemetryEvents, + completeEvent: TelemetryEvents, + errorEvent: TelemetryEvents, + properties: Record, + action: () => Promise | T, + ) { + this.track(startEvent, properties); + const start = Date.now(); + let success = true; + try { + return await action(); + } catch (err: any) { + this.track(errorEvent, { + message: err.message, + stack: err.stack, + ...properties, + }); + success = false; + throw err; + } finally { + this.track(completeEvent, { + duration: Date.now() - start, + success, + ...properties, + }); + } + } + + async trackCommand(command: string, action: () => Promise | void) { + await this.trackSpan('cli:command:start', 'cli:command:complete', 'cli:command:error', { command }, action); + } + + async trackCli(action: () => Promise | void) { + await this.trackSpan('cli:start', 'cli:complete', 'cli:error', {}, action); + } + + getPrismaVersion() { + try { + const packageJsonPath = import.meta.resolve('prisma/package.json'); + const packageJsonUrl = new URL(packageJsonPath); + const packageJson = JSON.parse(fs.readFileSync(packageJsonUrl, 'utf8')); + return packageJson.version; + } catch { + return undefined; + } + } +} + +export const telemetry = new Telemetry(); diff --git a/packages/cli/src/utils/is-ci.ts b/packages/cli/src/utils/is-ci.ts new file mode 100644 index 00000000..7fcfa366 --- /dev/null +++ b/packages/cli/src/utils/is-ci.ts @@ -0,0 +1,5 @@ +import { env } from 'node:process'; +export const isInCi = + env['CI'] !== '0' && + env['CI'] !== 'false' && + ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))); diff --git a/packages/cli/src/utils/is-container.ts b/packages/cli/src/utils/is-container.ts new file mode 100644 index 00000000..78c7937b --- /dev/null +++ b/packages/cli/src/utils/is-container.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import isDocker from './is-docker'; + +let cachedResult: boolean | undefined; + +// Podman detection +const hasContainerEnv = () => { + try { + fs.statSync('/run/.containerenv'); + return true; + } catch { + return false; + } +}; + +export function isInContainer() { + // TODO: Use `??=` when targeting Node.js 16. + if (cachedResult === undefined) { + cachedResult = hasContainerEnv() || isDocker(); + } + + return cachedResult; +} diff --git a/packages/cli/src/utils/is-docker.ts b/packages/cli/src/utils/is-docker.ts new file mode 100644 index 00000000..c44a12ed --- /dev/null +++ b/packages/cli/src/utils/is-docker.ts @@ -0,0 +1,31 @@ +// Copied over from https://github.com/sindresorhus/is-docker for CJS compatibility + +import fs from 'node:fs'; + +let isDockerCached: boolean | undefined; + +function hasDockerEnv() { + try { + fs.statSync('/.dockerenv'); + return true; + } catch { + return false; + } +} + +function hasDockerCGroup() { + try { + return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker'); + } catch { + return false; + } +} + +export default function isDocker() { + // TODO: Use `??=` when targeting Node.js 16. + if (isDockerCached === undefined) { + isDockerCached = hasDockerEnv() || hasDockerCGroup(); + } + + return isDockerCached; +} diff --git a/packages/cli/src/utils/is-wsl.ts b/packages/cli/src/utils/is-wsl.ts new file mode 100644 index 00000000..5d3c0078 --- /dev/null +++ b/packages/cli/src/utils/is-wsl.ts @@ -0,0 +1,18 @@ +import process from 'node:process'; +import os from 'node:os'; +import fs from 'node:fs'; +export const isWsl = () => { + if (process.platform !== 'linux') { + return false; + } + + if (os.release().toLowerCase().includes('microsoft')) { + return true; + } + + try { + return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); + } catch { + return false; + } +}; diff --git a/packages/cli/src/utils/machine-id-utils.ts b/packages/cli/src/utils/machine-id-utils.ts new file mode 100644 index 00000000..e8a8801c --- /dev/null +++ b/packages/cli/src/utils/machine-id-utils.ts @@ -0,0 +1,76 @@ +// modified from https://github.com/automation-stack/node-machine-id + +import { execSync } from 'child_process'; +import { createHash, randomUUID } from 'node:crypto'; + +const { platform } = process; +const win32RegBinPath = { + native: '%windir%\\System32', + mixed: '%windir%\\sysnative\\cmd.exe /c %windir%\\System32', +}; +const guid = { + darwin: 'ioreg -rd1 -c IOPlatformExpertDevice', + win32: + `${win32RegBinPath[isWindowsProcessMixedOrNativeArchitecture()]}\\REG.exe ` + + 'QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography ' + + '/v MachineGuid', + linux: '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname 2> /dev/null) | head -n 1 || :', + freebsd: 'kenv -q smbios.system.uuid || sysctl -n kern.hostuuid', +}; + +function isWindowsProcessMixedOrNativeArchitecture() { + // eslint-disable-next-line no-prototype-builtins + if (process.arch === 'ia32' && process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432')) { + return 'mixed'; + } + return 'native'; +} + +function hash(guid: string): string { + return createHash('sha256').update(guid).digest('hex'); +} + +function expose(result: string): string | undefined { + switch (platform) { + case 'darwin': + return result + .split('IOPlatformUUID')[1] + ?.split('\n')[0] + ?.replace(/=|\s+|"/gi, '') + .toLowerCase(); + case 'win32': + return result + .toString() + .split('REG_SZ')[1] + ?.replace(/\r+|\n+|\s+/gi, '') + .toLowerCase(); + case 'linux': + return result + .toString() + .replace(/\r+|\n+|\s+/gi, '') + .toLowerCase(); + case 'freebsd': + return result + .toString() + .replace(/\r+|\n+|\s+/gi, '') + .toLowerCase(); + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +} + +export function getMachineId() { + if (!(platform in guid)) { + return randomUUID(); + } + try { + const value = execSync(guid[platform as keyof typeof guid]); + const id = expose(value.toString()); + if (!id) { + return randomUUID(); + } + return hash(id); + } catch { + return randomUUID(); + } +} diff --git a/packages/cli/src/utils/version-utils.ts b/packages/cli/src/utils/version-utils.ts index 31e7a107..ad428cdf 100644 --- a/packages/cli/src/utils/version-utils.ts +++ b/packages/cli/src/utils/version-utils.ts @@ -1,6 +1,11 @@ +import colors from 'colors'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import semver from 'semver'; + +const CHECK_VERSION_TIMEOUT = 2000; +const VERSION_CHECK_TAG = 'next'; export function getVersion() { try { @@ -11,3 +16,35 @@ export function getVersion() { return undefined; } } + +export async function checkNewVersion() { + const currVersion = getVersion(); + let latestVersion: string; + try { + latestVersion = await getLatestVersion(); + } catch { + // noop + return; + } + + if (latestVersion && currVersion && semver.gt(latestVersion, currVersion)) { + console.log(`A newer version ${colors.cyan(latestVersion)} is available.`); + } +} + +export async function getLatestVersion() { + const fetchResult = await fetch(`https://registry.npmjs.org/@zenstackhq/cli/${VERSION_CHECK_TAG}`, { + headers: { accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*' }, + signal: AbortSignal.timeout(CHECK_VERSION_TIMEOUT), + }); + + if (fetchResult.ok) { + const data: any = await fetchResult.json(); + const latestVersion = data?.version; + if (typeof latestVersion === 'string' && semver.valid(latestVersion)) { + return latestVersion; + } + } + + throw new Error('invalid npm registry response'); +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 2496f3ea..c1881d32 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { defineConfig } from 'tsup'; export default defineConfig({ @@ -10,4 +12,19 @@ export default defineConfig({ clean: true, dts: true, format: ['esm', 'cjs'], + onSuccess: async () => { + if (!process.env['TELEMETRY_TRACKING_TOKEN']) { + return; + } + const filesToProcess = ['dist/index.js', 'dist/index.cjs']; + for (const file of filesToProcess) { + console.log(`Processing ${file} for telemetry token...`); + const content = fs.readFileSync(path.join(__dirname, file), 'utf-8'); + const updatedContent = content.replace( + '', + process.env['TELEMETRY_TRACKING_TOKEN'], + ); + fs.writeFileSync(file, updatedContent, 'utf-8'); + } + }, }); diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 826dde3e..db2f115e 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index dcd6d334..c8053f93 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/create-zenstack/src/index.ts b/packages/create-zenstack/src/index.ts index 092a784e..14af8a12 100644 --- a/packages/create-zenstack/src/index.ts +++ b/packages/create-zenstack/src/index.ts @@ -1,6 +1,6 @@ import colors from 'colors'; import { Command } from 'commander'; -import { execSync } from 'node:child_process'; +import { execSync, type StdioOptions } from 'node:child_process'; import fs from 'node:fs'; import ora from 'ora'; import { STARTER_MAIN_TS, STARTER_ZMODEL } from './templates'; @@ -61,6 +61,9 @@ function initProject(name: string) { ), ); + // create VSCode config files + createVsCodeConfig(); + // install packages const packages = [ { name: '@zenstackhq/cli@next', dev: true }, @@ -103,24 +106,46 @@ function initProject(name: string) { // create main.ts fs.writeFileSync('main.ts', STARTER_MAIN_TS); - // run `zenstack generate` - runCommand(`${agentExec} zenstack generate`, 'Running `zenstack generate`'); + // run `zen generate` + runCommand(`${agentExec} zen generate`, 'Running `zen generate`'); + + // run `zen db push` + runCommand(`${agentExec} zen db push`, 'Running `zen db push`'); - // run `zenstack db push` - runCommand(`${agentExec} zenstack db push`, 'Running `zenstack db push`'); + // run `$agent run dev` + console.log(`Running \`${agent} run dev\``); + execSync(`${agent} run dev`, { stdio: 'inherit' }); + console.log(colors.green('Project setup completed!')); } function installPackage(pkg: { name: string; dev: boolean }) { runCommand(`${agent} install ${pkg.name} ${pkg.dev ? saveDev : ''}`, `Installing "${pkg.name}"`); } -function runCommand(cmd: string, status: string) { +function runCommand(cmd: string, status: string, stdio: StdioOptions = 'ignore') { const spinner = ora(status).start(); try { - execSync(cmd); + execSync(cmd, { stdio }); spinner.succeed(); } catch (e) { spinner.fail(); throw e; } } + +function createVsCodeConfig() { + fs.mkdirSync('.vscode', { recursive: true }); + fs.writeFileSync( + '.vscode/settings.json', + JSON.stringify( + { + 'files.associations': { + '*.zmodel': 'zmodel-v3', + }, + }, + null, + 4, + ), + ); + fs.writeFileSync('.vscode/extensions.json', JSON.stringify({ recommendations: ['zenstack.zenstack-v3'] }, null, 4)); +} diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json index 89f0aff5..2048c55f 100644 --- a/packages/dialects/sql.js/package.json +++ b/packages/dialects/sql.js/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/kysely-sql-js", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "Kysely dialect for sql.js", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index c453274d..8c284f43 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/.DS_Store b/packages/ide/.DS_Store new file mode 100644 index 00000000..1de9626a Binary files /dev/null and b/packages/ide/.DS_Store differ diff --git a/packages/ide/vscode/README.md b/packages/ide/vscode/README.md index 44d12fd1..cf57bcc5 100644 --- a/packages/ide/vscode/README.md +++ b/packages/ide/vscode/README.md @@ -1,48 +1,62 @@ -# ZenStack VS Code Extension +# ZenStack V3 VS Code Extension -[ZenStack](https://zenstack.dev) is a toolkit that simplifies the development of a web app's backend. It enhances [Prisma ORM](https://prisma.io) with flexible Authorization and auto-generated, type-safe APIs/hooks, simplifying full-stack development. +[ZenStack](https://zenstack.dev) is the modern data layer for TypeScript applications. It provides a data modeling language, a type-safe ORM with built-in access control, and other utilities that help you streamline full-stack development. This VS Code extension provides code editing helpers for authoring ZenStack's schema files (`.zmodel` files). -This VS Code extension provides code editing helpers for authoring ZenStack's schema files (.zmodel files). +Use this extension if you are using ZenStack v3.x, otherwise use the [original extension](https://marketplace.visualstudio.com/items?itemName=zenstack.zenstack) that works with v2.x. See [Configuration](#configuration) for how to use both versions side by side. ## Features -- Syntax highlighting of `*.zmodel` files +- Syntax highlighting +- Inline error reporting +- Go-to definition +- Hover documentation +- Code section folding - - In case the schema file is not recognized automatically, add the following to your settings.json file: +## Configuration - ```json - "files.associations": { - "*.zmodel": "zmodel" - }, - ``` +### Side by side with the original ZenStack extension -- Auto formatting +If you have the [original ZenStack v2 extension](https://marketplace.visualstudio.com/items?itemName=zenstack.zenstack) installed, it may compete with this extension on handling `.zmodel` files. In this case, add the following settings to your `.vscode/settings.json` file to specify which extension should handle `.zmodel` files. - - To automatically format on save, add the following to your settings.json file: +To let this extension handle `.zmodel` files, add: - ```json - "editor.formatOnSave": true - ``` +```json +"files.associations": { + "*.zmodel": "zmodel-v3" +}, +``` - - To enable formatting in combination with prettier, add the following to your settings.json file: - ```json - "[zmodel]": { - "editor.defaultFormatter": "zenstack.zenstack" - }, - ``` +To let the v2 extension handle `.zmodel` files, add: -- Inline error reporting -- Go-to definition -- Hover documentation -- Code section folding +```json +"files.associations": { + "*.zmodel": "zmodel" +}, +``` + +### Auto formatting + +To automatically format on save, add the following to your `.vscode/settings.json` file: + +```json +"editor.formatOnSave": true +``` + +To enable formatting in combination with prettier, add the following to your `.vscode/settings.json` file: + +```json +"[zmodel-v3]": { + "editor.defaultFormatter": "zenstack.zenstack-v3" +}, +``` ## Links -- [Home](https://zenstack.dev) -- [Documentation](https://zenstack.dev/docs) +- [Home](https://zenstack.dev/v3) +- [Documentation](https://zenstack.dev/docs/3.x) - [Community chat](https://discord.gg/Ykhr738dUe) - [Twitter](https://twitter.com/zenstackhq) -- [Blog](https://dev.to/zenstack) +- [Blog](https://zenstack.dev/blog) ## Community diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 8b183adc..56b10f3f 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,9 +1,9 @@ { - "name": "zenstack", + "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.0.0-alpha.33", - "displayName": "ZenStack Language Tools", - "description": "VSCode extension for ZenStack ZModel language", + "version": "3.0.2", + "displayName": "ZenStack V3 Language Tools", + "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, "repository": { "type": "git", @@ -12,8 +12,8 @@ "scripts": { "build": "tsc --noEmit && tsup", "lint": "eslint src --ext ts", - "vscode:publish": "pnpm build && vsce publish --no-dependencies --pre-release --follow-symlinks", - "vscode:package": "pnpm build && vsce package --no-dependencies" + "vscode:publish": "pnpm build && vsce publish --no-dependencies --follow-symlinks", + "vscode:package": "pnpm build && vsce package --no-dependencies --follow-symlinks" }, "homepage": "https://zenstack.dev", "icon": "asset/logo-256-bg.png", @@ -36,7 +36,7 @@ "vscode-languageserver": "^9.0.1" }, "devDependencies": { - "@types/vscode": "^1.63.0", + "@types/vscode": "^1.90.0", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*" }, @@ -48,8 +48,8 @@ "language-configuration.json" ], "engines": { - "vscode": "^1.63.0", - "node": ">=18.0.0" + "vscode": "^1.90.0", + "node": ">=20.0.0" }, "categories": [ "Programming Languages" @@ -57,7 +57,7 @@ "contributes": { "languages": [ { - "id": "zmodel", + "id": "zmodel-v3", "aliases": [ "ZenStack Model", "zmodel" @@ -74,14 +74,14 @@ ], "grammars": [ { - "language": "zmodel", + "language": "zmodel-v3", "scopeName": "source.zmodel", "path": "./syntaxes/zmodel.tmLanguage.json" } ] }, "activationEvents": [ - "onLanguage:zmodel" + "onLanguage:zmodel-v3" ], "main": "./dist/extension.js" } diff --git a/packages/ide/vscode/src/extension/main.ts b/packages/ide/vscode/src/extension/main.ts index fed30b20..a7fab381 100644 --- a/packages/ide/vscode/src/extension/main.ts +++ b/packages/ide/vscode/src/extension/main.ts @@ -43,11 +43,11 @@ function startLanguageClient(context: vscode.ExtensionContext): LanguageClient { // Options to control the language client const clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: '*', language: 'zmodel' }], + documentSelector: [{ language: 'zmodel-v3' }], }; // Create the language client and start the client. - const client = new LanguageClient('zmodel', 'ZModel', serverOptions, clientOptions); + const client = new LanguageClient('zmodel-v3', 'ZenStack Model V3', serverOptions, clientOptions); // Start the client. This will also launch the server client.start(); diff --git a/packages/language/package.json b/packages/language/package.json index e7af28d6..032d1052 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/language/src/generated/module.ts b/packages/language/src/generated/module.ts index 58849979..0c621d00 100644 --- a/packages/language/src/generated/module.ts +++ b/packages/language/src/generated/module.ts @@ -8,7 +8,7 @@ import { ZModelAstReflection } from './ast.js'; import { ZModelGrammar } from './grammar.js'; export const ZModelLanguageMetaData = { - languageId: 'zmodel', + languageId: 'zmodel-v3', fileExtensions: ['.zmodel'], caseInsensitive: false, mode: 'development' diff --git a/packages/language/src/validators/typedef-validator.ts b/packages/language/src/validators/typedef-validator.ts index d029d8ba..6ad35b0b 100644 --- a/packages/language/src/validators/typedef-validator.ts +++ b/packages/language/src/validators/typedef-validator.ts @@ -1,5 +1,5 @@ import type { ValidationAcceptor } from 'langium'; -import type { DataField, TypeDef } from '../generated/ast'; +import { isDataModel, type DataField, type TypeDef } from '../generated/ast'; import { validateAttributeApplication } from './attribute-application-validator'; import { validateDuplicatedDeclarations, type AstValidator } from './common'; @@ -22,6 +22,11 @@ export default class TypeDefValidator implements AstValidator { } private validateField(field: DataField, accept: ValidationAcceptor): void { + if (isDataModel(field.type.reference?.ref)) { + accept('error', 'Type field cannot be a relation', { + node: field.type, + }); + } field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); } } diff --git a/packages/language/test/mixin.test.ts b/packages/language/test/mixin.test.ts index 3832148d..8e7bcd0a 100644 --- a/packages/language/test/mixin.test.ts +++ b/packages/language/test/mixin.test.ts @@ -106,4 +106,19 @@ describe('Mixin Tests', () => { 'can only be applied once', ); }); + + it('does not allow relation fields in type', async () => { + await loadSchemaWithError( + ` + model User { + id Int @id @default(autoincrement()) + } + + type T { + u User + } + `, + 'Type field cannot be a relation', + ); + }); }); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index eab7dc0d..669aea4b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/runtime/test/policy/client-extensions.test.ts b/packages/runtime/test/policy/client-extensions.test.ts index f1f916b4..1f725172 100644 --- a/packages/runtime/test/policy/client-extensions.test.ts +++ b/packages/runtime/test/policy/client-extensions.test.ts @@ -22,7 +22,7 @@ describe('client extensions tests for policies', () => { await rawDb.model.create({ data: { x: 2, y: 300 } }); const ext = definePlugin({ - id: 'prisma-extension-queryOverride', + id: 'queryOverride', onQuery: async ({ args, proceed }: any) => { args = args ?? {}; args.where = { ...args.where, y: { lt: 300 } }; @@ -53,7 +53,7 @@ describe('client extensions tests for policies', () => { await rawDb.model.create({ data: { x: 2, y: 300 } }); const ext = definePlugin({ - id: 'prisma-extension-queryOverride', + id: 'queryOverride', onQuery: async ({ args, proceed }: any) => { args = args ?? {}; args.where = { ...args.where, y: { lt: 300 } }; @@ -84,7 +84,7 @@ describe('client extensions tests for policies', () => { await rawDb.model.create({ data: { x: 2, y: 300 } }); const ext = definePlugin({ - id: 'prisma-extension-queryOverride', + id: 'queryOverride', onQuery: async ({ args, proceed }: any) => { args = args ?? {}; args.where = { ...args.where, y: { lt: 300 } }; @@ -115,7 +115,7 @@ describe('client extensions tests for policies', () => { await rawDb.model.create({ data: { x: 2, y: 300 } }); const ext = definePlugin({ - id: 'prisma-extension-queryOverride', + id: 'queryOverride', onQuery: async ({ args, proceed }: any) => { args = args ?? {}; args.where = { ...args.where, y: { lt: 300 } }; @@ -144,7 +144,7 @@ describe('client extensions tests for policies', () => { await rawDb.model.create({ data: { value: 1 } }); const ext = definePlugin({ - id: 'prisma-extension-resultMutation', + id: 'resultMutation', onQuery: async ({ args, proceed }: any) => { const r: any = await proceed(args); for (let i = 0; i < r.length; i++) { diff --git a/packages/runtime/test/policy/mixin.test.ts b/packages/runtime/test/policy/mixin.test.ts new file mode 100644 index 00000000..247e9864 --- /dev/null +++ b/packages/runtime/test/policy/mixin.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from './utils'; + +describe('Abstract models', () => { + it('connect test1', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + profile Profile? @relation(fields: [profileId], references: [id]) + profileId Int? @unique + + @@allow('create,read', true) + @@allow('update', auth().id == 1) + } + + type BaseProfile { + id Int @id @default(autoincrement()) + + @@allow('all', true) + } + + model Profile with BaseProfile { + name String + user User? + } + `, + ); + + const dbUser2 = db.$setAuth({ id: 2 }); + const user = await dbUser2.user.create({ data: { id: 1 } }); + const profile = await dbUser2.profile.create({ data: { id: 1, name: 'John' } }); + await expect( + dbUser2.profile.update({ where: { id: 1 }, data: { user: { connect: { id: user.id } } } }), + ).toBeRejectedNotFound(); + await expect( + dbUser2.user.update({ where: { id: 1 }, data: { profile: { connect: { id: profile.id } } } }), + ).toBeRejectedNotFound(); + + const dbUser1 = db.$setAuth({ id: 1 }); + await expect( + dbUser1.profile.update({ where: { id: 1 }, data: { user: { connect: { id: user.id } } } }), + ).toResolveTruthy(); + await expect( + dbUser1.user.update({ where: { id: 1 }, data: { profile: { connect: { id: profile.id } } } }), + ).toResolveTruthy(); + }); + + it('connect test2', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + profile Profile? + + @@allow('all', true) + } + + type BaseProfile { + id Int @id @default(autoincrement()) + + @@allow('create,read', true) + @@allow('update', auth().id == 1) + } + + model Profile with BaseProfile { + name String + user User? @relation(fields: [userId], references: [id]) + userId Int? @unique + } + `, + ); + + const dbUser2 = db.$setAuth({ id: 2 }); + const user = await dbUser2.user.create({ data: { id: 1 } }); + const profile = await dbUser2.profile.create({ data: { id: 1, name: 'John' } }); + await expect( + dbUser2.profile.update({ where: { id: 1 }, data: { user: { connect: { id: user.id } } } }), + ).toBeRejectedNotFound(); + await expect( + dbUser2.user.update({ where: { id: 1 }, data: { profile: { connect: { id: profile.id } } } }), + ).toBeRejectedNotFound(); + + const dbUser1 = db.$setAuth({ id: 1 }); + await expect( + dbUser1.profile.update({ where: { id: 1 }, data: { user: { connect: { id: user.id } } } }), + ).toResolveTruthy(); + await expect( + dbUser1.user.update({ where: { id: 1 }, data: { profile: { connect: { id: profile.id } } } }), + ).toResolveTruthy(); + }); +}); diff --git a/packages/runtime/test/policy/multi-field-unique.test.ts b/packages/runtime/test/policy/multi-field-unique.test.ts new file mode 100644 index 00000000..029bdaeb --- /dev/null +++ b/packages/runtime/test/policy/multi-field-unique.test.ts @@ -0,0 +1,179 @@ +import path from 'path'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from './utils'; +import { QueryError } from '../../src'; + +describe('With Policy: multi-field unique', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('toplevel crud test unnamed constraint', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + a String + b String + x Int + @@unique([a, b]) + + @@allow('all', x > 0) + @@deny('update', x > 1) + } + `, + ); + + await expect(db.model.create({ data: { a: 'a1', b: 'b1', x: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: { a: 'a1', b: 'b1', x: 2 } })).rejects.toThrow(QueryError); + await expect(db.model.create({ data: { a: 'a2', b: 'b2', x: 0 } })).toBeRejectedByPolicy(); + + await expect(db.model.findUnique({ where: { a_b: { a: 'a1', b: 'b1' } } })).toResolveTruthy(); + await expect(db.model.findUnique({ where: { a_b: { a: 'a1', b: 'b2' } } })).toResolveFalsy(); + await expect(db.model.update({ where: { a_b: { a: 'a1', b: 'b1' } }, data: { x: 2 } })).toResolveTruthy(); + await expect(db.model.update({ where: { a_b: { a: 'a1', b: 'b1' } }, data: { x: 0 } })).toBeRejectedNotFound(); + + await expect(db.model.delete({ where: { a_b: { a: 'a1', b: 'b1' } } })).toResolveTruthy(); + }); + + it('toplevel crud test named constraint', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + a String + b String + x Int + @@unique([a, b], name: 'myconstraint') + + @@allow('all', x > 0) + @@deny('update', x > 1) + } + `, + ); + + await expect(db.model.create({ data: { a: 'a1', b: 'b1', x: 1 } })).toResolveTruthy(); + await expect(db.model.findUnique({ where: { myconstraint: { a: 'a1', b: 'b1' } } })).toResolveTruthy(); + await expect(db.model.findUnique({ where: { myconstraint: { a: 'a1', b: 'b2' } } })).toResolveFalsy(); + await expect( + db.model.update({ where: { myconstraint: { a: 'a1', b: 'b1' } }, data: { x: 2 } }), + ).toResolveTruthy(); + await expect( + db.model.update({ where: { myconstraint: { a: 'a1', b: 'b1' } }, data: { x: 0 } }), + ).toBeRejectedNotFound(); + await expect(db.model.delete({ where: { myconstraint: { a: 'a1', b: 'b1' } } })).toResolveTruthy(); + }); + + it('nested crud test', async () => { + const db = await createPolicyTestClient( + ` + model M1 { + id String @id @default(uuid()) + m2 M2[] + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + a String + b String + x Int + m1 M1 @relation(fields: [m1Id], references: [id]) + m1Id String + + @@unique([a, b]) + @@allow('all', x > 0) + } + `, + ); + + await expect(db.m1.create({ data: { id: '1', m2: { create: { a: 'a1', b: 'b1', x: 1 } } } })).toResolveTruthy(); + await expect(db.m1.create({ data: { id: '2', m2: { create: { a: 'a1', b: 'b1', x: 2 } } } })).rejects.toThrow( + QueryError, + ); + await expect( + db.m1.create({ data: { id: '3', m2: { create: { a: 'a1', b: 'b2', x: 0 } } } }), + ).toBeRejectedByPolicy(); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + connectOrCreate: { + where: { a_b: { a: 'a1', b: 'b1' } }, + create: { a: 'a1', b: 'b1', x: 2 }, + }, + }, + }, + }), + ).toResolveTruthy(); + await expect(db.m2.count()).resolves.toBe(1); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + connectOrCreate: { + where: { a_b: { a: 'a1', b: 'b2' } }, + create: { a: 'a1', b: 'b2', x: 2 }, + }, + }, + }, + }), + ).toResolveTruthy(); + await expect(db.m2.count()).resolves.toBe(2); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + connectOrCreate: { + where: { a_b: { a: 'a2', b: 'b2' } }, + create: { a: 'a2', b: 'b2', x: 0 }, + }, + }, + }, + }), + ).toBeRejectedByPolicy(); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + update: { + where: { a_b: { a: 'a1', b: 'b2' } }, + data: { x: 3 }, + }, + }, + }, + }), + ).toResolveTruthy(); + await expect(db.m2.findUnique({ where: { a_b: { a: 'a1', b: 'b2' } } })).resolves.toEqual( + expect.objectContaining({ x: 3 }), + ); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + delete: { + a_b: { a: 'a1', b: 'b1' }, + }, + }, + }, + }), + ).toResolveTruthy(); + await expect(db.m2.count()).resolves.toBe(1); + }); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cfc8c574..02b66309 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 7484e700..d2e8ba64 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -795,14 +795,14 @@ export class TsSchemaGenerator { ); } else { // multi-field unique - const key = fieldNames.join('_'); + const key = this.getCompoundUniqueKey(attr, fieldNames); if (seenKeys.has(key)) { continue; } seenKeys.add(key); properties.push( ts.factory.createPropertyAssignment( - fieldNames.join('_'), + key, ts.factory.createObjectLiteralExpression( fieldNames.map((field) => { const fieldDef = allFields.find((f) => f.name === field)!; @@ -826,6 +826,15 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(properties, true); } + private getCompoundUniqueKey(attr: DataModelAttribute, fieldNames: string[]) { + const nameArg = attr.args.find((arg) => arg.$resolvedParam.name === 'name'); + if (nameArg && isLiteralExpr(nameArg.value)) { + return nameArg.value.value as string; + } else { + return fieldNames.join('_'); + } + } + private generateFieldTypeLiteral(field: DataField): ts.Expression { invariant( field.type.type || field.type.reference || field.type.unsupported, diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index f56ad049..cc9ddbb8 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 83762c2a..5a561b9e 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index bdc3b8fe..476d5cd9 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "private": true, "license": "MIT" } diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json index 34cb5524..72abe24f 100644 --- a/packages/vitest-config/package.json +++ b/packages/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "private": true, "license": "MIT", "exports": { diff --git a/packages/zod/package.json b/packages/zod/package.json index 5281b1fb..26d1fd55 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "", "type": "module", "main": "index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70b5b1db..82877877 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: langium: specifier: 'catalog:' version: 3.5.0 + mixpanel: + specifier: ^0.18.1 + version: 0.18.1 ora: specifier: ^5.4.1 version: 5.4.1 @@ -104,6 +107,9 @@ importers: prisma: specifier: 'catalog:' version: 6.14.0(typescript@5.8.3) + semver: + specifier: ^7.7.2 + version: 7.7.2 ts-pattern: specifier: 'catalog:' version: 5.7.1 @@ -111,6 +117,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.0 '@types/tmp': specifier: 'catalog:' version: 0.2.6 @@ -203,7 +212,7 @@ importers: version: 9.0.1 devDependencies: '@types/vscode': - specifier: ^1.63.0 + specifier: ^1.90.0 version: 1.101.0 '@zenstackhq/eslint-config': specifier: workspace:* @@ -1122,6 +1131,9 @@ packages: '@types/pluralize@0.0.33': resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/semver@7.7.0': + resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} + '@types/sql.js@1.4.9': resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==} @@ -1232,6 +1244,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1712,6 +1728,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + https-proxy-agent@5.0.0: + resolution: {integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1920,6 +1940,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mixpanel@0.18.1: + resolution: {integrity: sha512-YD1xfn6WP6ZLQ6Pmgh0KgdXhueJEsrodThMTsHzHMH0VbWa9ck8s+ynDtM83OSgt+yQ61W/SQNrH8Y4wIwocGg==} + engines: {node: '>=10.0'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -2246,11 +2270,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -2291,6 +2310,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} @@ -3122,6 +3142,8 @@ snapshots: '@types/pluralize@0.0.33': {} + '@types/semver@7.7.0': {} + '@types/sql.js@1.4.9': dependencies: '@types/emscripten': 1.40.1 @@ -3273,6 +3295,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3815,6 +3843,13 @@ snapshots: dependencies: function-bind: 1.1.2 + https-proxy-agent@5.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -3994,6 +4029,12 @@ snapshots: minipass@7.1.2: {} + mixpanel@0.18.1: + dependencies: + https-proxy-agent: 5.0.0 + transitivePeerDependencies: + - supports-color + mkdirp-classic@0.5.3: {} mlly@1.7.4: @@ -4320,8 +4361,6 @@ snapshots: safe-buffer@5.2.1: {} - semver@7.6.3: {} - semver@7.7.2: {} set-function-length@1.2.2: @@ -4663,7 +4702,7 @@ snapshots: vscode-languageclient@9.0.1: dependencies: minimatch: 5.1.6 - semver: 7.6.3 + semver: 7.7.2 vscode-languageserver-protocol: 3.17.5 vscode-languageserver-protocol@3.17.5: diff --git a/samples/blog/package.json b/samples/blog/package.json index 59ca3e91..507fa5a5 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "description": "", "main": "index.js", "scripts": { diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts index ff4d353f..53057d4a 100644 --- a/scripts/bump-version.ts +++ b/scripts/bump-version.ts @@ -3,6 +3,8 @@ import { glob } from 'glob'; import * as path from 'node:path'; import * as yaml from 'yaml'; +const excludes = ['packages/ide/vscode/package.json']; + function getWorkspacePackageJsonFiles(workspaceFile: string): string[] { const workspaceYaml = fs.readFileSync(workspaceFile, 'utf8'); const workspace = yaml.parse(workspaceYaml) as { packages?: string[] }; @@ -23,7 +25,8 @@ function getWorkspacePackageJsonFiles(workspaceFile: string): string[] { // include root package.json files.add(path.resolve(__dirname, '../package.json')); - return Array.from(files); + const result = Array.from(files).filter((f) => !excludes.some((e) => f.endsWith(e))); + return result; } function incrementVersion(version: string): string { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index e6ba74bb..ee90ccdb 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-alpha.33", + "version": "3.0.0-beta.1", "private": true, "type": "module", "scripts": {