diff --git a/README.md b/README.md index 40a93725..949162d8 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ npm install -D @types/pg Run the following command to sync schema to the database for local development: ```bash -npx zenstack db push +npx zen db push ``` > Under the hood, the command uses `prisma db push` to do the job. @@ -127,17 +127,17 @@ See [database migration](#database-migration) for how to use migration to manage ## Compiling ZModel schema -ZModel needs to be compiled to TypeScript before being used to create a database client. Simply run the following command: +ZModel needs to be compiled to TypeScript before being used to create a database db. Simply run the following command: ```bash -npx zenstack generate +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 client. +Now you can use the compiled TypeScript schema to instantiate a database db. ### SQLite @@ -147,7 +147,7 @@ import { schema } from './zenstack/schema'; import SQLite from 'better-sqlite3'; import { SqliteDialect } from 'kysely'; -const client = new ZenStackClient(schema, { +const db = new ZenStackClient(schema, { dialect: new SqliteDialect({ database: new SQLite('./dev.db') }), }); ``` @@ -159,11 +159,10 @@ import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './zenstack/schema'; import { PostgresDialect } from 'kysely'; import { Pool } from 'pg'; -import { parseIntoClientConfig } from 'pg-connection-string'; -const client = new ZenStackClient(schema, { +const db = new ZenStackClient(schema, { dialect: new PostgresDialect({ - pool: new Pool(parseIntoClientConfig(process.env.DATABASE_URL)), + pool: new Pool({ connectionString: process.env.DATABASE_URL }), }), }); ``` @@ -177,7 +176,7 @@ const client = new ZenStackClient(schema, { A few quick examples: ```ts -const user = await client.user.create({ +const user = await db.user.create({ data: { name: 'Alex', email: 'alex@zenstack.dev', @@ -185,12 +184,12 @@ const user = await client.user.create({ }, }); -const userWithPosts = await client.user.findUnique({ +const userWithPosts = await db.user.findUnique({ where: { id: user.id }, include: { posts: true }, }); -const groupedPosts = await client.post.groupBy({ +const groupedPosts = await db.post.groupBy({ by: 'published', _count: true, }); @@ -205,7 +204,7 @@ ZenStack uses Kysely to handle database operations, and it also directly exposes Please check [Kysely documentation](https://kysely.dev/docs/intro) for more details. Here're a few quick examples: ```ts -await client.$qb +await db.$qb .selectFrom('User') .leftJoin('Post', 'Post.authorId', 'User.id') .select(['User.id', 'User.email', 'Post.title']) @@ -215,7 +214,7 @@ await client.$qb 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 client.user.findMany({ +await db.user.findMany({ where: { age: { gt: 18 }, // "eb" is a Kysely expression builder @@ -243,7 +242,7 @@ ZenStack v3 allows you to define database-evaluated computed fields with the fol 2. Provide its implementation using query builder when constructing `ZenStackClient` ```ts - const client = new ZenStackClient(schema, { + const db = new ZenStackClient(schema, { ... computedFields: { User: { @@ -279,7 +278,7 @@ _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 `ZenStackClient.$use` API. +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: @@ -288,7 +287,7 @@ You can use a plugin to achieve the following goals: 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 -client.$use({ +db.$use({ id: 'cost-logger', onQuery: { $allModels: { @@ -312,7 +311,7 @@ Kysely query interception allows you to intercept the low-level query builder AP Kysely query interception works against the low-level Kysely `OperationNode` structures. It's harder to implement but can guarantee intercepting all CRUD operations. ```ts -client.$use({ +db.$use({ id: 'insert-interception-plugin', onKyselyQuery({query, proceed}) { if (query.kind === 'InsertQueryNode') { @@ -332,7 +331,7 @@ function sanitizeInsertData(query: InsertQueryNode) { Another popular interception use case is, instead of intercepting calls, "listening on" entity changes. ```ts -client.$use({ +db.$use({ id: 'mutation-hook-plugin', beforeEntityMutation({ model, action }) { console.log(`Before ${model} ${action}`); @@ -346,7 +345,7 @@ client.$use({ 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 -client.$use({ +db.$use({ id: 'mutation-hook-plugin', mutationInterceptionFilter: ({ model }) => { return { @@ -375,19 +374,19 @@ ZenStack v3 delegates database schema migration to Prisma. The CLI provides Pris - Sync schema to dev database and create a migration record: ```bash - npx zenstack migrate dev + npx zen migrate dev ``` - Deploy new migrations: ```bash - npx zenstack migrate deploy + npx zen migrate deploy ``` - Reset dev database ```bash - npx zenstack migrate reset + npx zen migrate reset ``` See [Prisma Migrate](https://www.prisma.io/docs/orm/prisma-migrate) documentation for more details. @@ -398,7 +397,7 @@ See [Prisma Migrate](https://www.prisma.io/docs/orm/prisma-migrate) documentatio 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 zenstack generate` +1. Run `npx zen generate` 1. Replace `new PrismaClient()` with `new ZenStackClient(schema, { ... })` # Limitations diff --git a/TODO.md b/TODO.md index 270a15aa..7245aef9 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,10 @@ - [x] validate - [ ] format - [ ] db seed + - [ ] plugin mechanism + - [ ] built-in plugins + - [ ] ts + - [ ] prisma - [ ] ZModel - [ ] Import - [ ] View support @@ -73,6 +77,7 @@ - [x] Compound ID - [ ] Cross field comparison - [x] Many-to-many relation + - [ ] Self relation - [ ] Empty AND/OR/NOT behavior - [x] Logging - [x] Error system diff --git a/package.json b/package.json index 634deacd..43df7d8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index ef955248..dc3569ef 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.16", + "version": "3.0.0-alpha.17", "type": "module", "author": { "name": "ZenStack Team" @@ -49,6 +49,7 @@ "@zenstackhq/runtime": "workspace:*", "@zenstackhq/testtools": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", "better-sqlite3": "^11.8.1", "tmp": "catalog:" } diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index a6e4ec2d..287c5593 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -36,11 +36,10 @@ export function getSchemaFile(file?: string) { export async function loadSchemaDocument(schemaFile: string) { const loadResult = await loadDocument(schemaFile); if (!loadResult.success) { - console.error(colors.red('Error loading schema:')); loadResult.errors.forEach((err) => { console.error(colors.red(err)); }); - throw new CliError('Failed to load schema'); + throw new CliError('Schema contains errors. See above for details.'); } loadResult.warnings.forEach((warn) => { console.warn(colors.yellow(warn)); diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 12d58d82..58c2060a 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -52,8 +52,7 @@ import { schema } from '${outputPath}/schema'; const client = new ZenStackClient(schema, { dialect: { ... } }); -\`\`\` -`); +\`\`\``); } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5e16d0b2..fc154121 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,8 @@ import { ZModelLanguageMetaData } from '@zenstackhq/language'; import colors from 'colors'; -import { Command, Option } from 'commander'; +import { Command, CommanderError, Option } from 'commander'; import * as actions from './actions'; +import { CliError } from './cli-error'; import { getVersion } from './utils/version-utils'; const generateAction = async (options: Parameters[0]): Promise => { @@ -125,4 +126,17 @@ export function createProgram() { } const program = createProgram(); -program.parse(process.argv); + +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); + } else { + console.error(colors.red('An unexpected error occurred:')); + console.error(err); + process.exit(1); + } +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 04655403..75a9f709 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,4 +1,4 @@ +import base from '@zenstackhq/vitest-config/base'; import { defineConfig, mergeConfig } from 'vitest/config'; -import base from '../../vitest.base.config'; export default mergeConfig(base, defineConfig({})); diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 750eaa8e..23846e5c 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.16", + "version": "3.0.0-alpha.17", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 6acf0498..31dafba6 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.16", + "version": "3.0.0-alpha.17", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/dialects/sql.js/README.md b/packages/dialects/sql.js/README.md new file mode 100644 index 00000000..e5ff101f --- /dev/null +++ b/packages/dialects/sql.js/README.md @@ -0,0 +1,25 @@ +Forked from https://github.com/betarixm/kysely-sql-js + +## Usage + +```ts +import { type GeneratedAlways, Kysely } from 'kysely'; +import initSqlJs from 'sql.js'; + +import { SqlJsDialect } from '@zenstackhq/kysely-sql-js'; + +interface Database { + person: { + id: GeneratedAlways; + first_name: string | null; + last_name: string | null; + age: number; + }; +} + +const SqlJsStatic = await initSqlJs(); + +export const db = new Kysely({ + dialect: new SqlJsDialect({ sqlJs: new SqlJsStatic.Database() }), +}); +``` diff --git a/packages/dialects/sql.js/eslint.config.js b/packages/dialects/sql.js/eslint.config.js new file mode 100644 index 00000000..5698b991 --- /dev/null +++ b/packages/dialects/sql.js/eslint.config.js @@ -0,0 +1,4 @@ +import config from '@zenstackhq/eslint-config/base.js'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json new file mode 100644 index 00000000..fa7386c5 --- /dev/null +++ b/packages/dialects/sql.js/package.json @@ -0,0 +1,42 @@ +{ + "name": "@zenstackhq/kysely-sql-js", + "version": "3.0.0-alpha.17", + "description": "Kysely dialect for sql.js", + "type": "module", + "scripts": { + "build": "tsup-node", + "watch": "tsup-node --watch", + "lint": "eslint src --ext ts", + "pack": "pnpm pack" + }, + "keywords": [], + "author": "ZenStack Team", + "license": "MIT", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "devDependencies": { + "@types/sql.js": "^1.4.9", + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", + "sql.js": "^1.13.0", + "kysely": "catalog:" + }, + "peerDependencies": { + "sql.js": "^1.13.0", + "kysely": "catalog:" + } +} diff --git a/packages/dialects/sql.js/src/connection.ts b/packages/dialects/sql.js/src/connection.ts new file mode 100644 index 00000000..94e1fa45 --- /dev/null +++ b/packages/dialects/sql.js/src/connection.ts @@ -0,0 +1,30 @@ +import type { DatabaseConnection, QueryResult } from 'kysely'; +import type { BindParams, Database } from 'sql.js'; + +import { CompiledQuery } from 'kysely'; + +export class SqlJsConnection implements DatabaseConnection { + private database: Database; + + constructor(database: Database) { + this.database = database; + } + + async executeQuery(compiledQuery: CompiledQuery): Promise> { + const executeResult = this.database.exec(compiledQuery.sql, compiledQuery.parameters as BindParams); + const rowsModified = this.database.getRowsModified(); + return { + numAffectedRows: BigInt(rowsModified), + rows: executeResult + .map(({ columns, values }) => + values.map((row) => columns.reduce((acc, column, i) => ({ ...acc, [column]: row[i] }), {}) as R), + ) + .flat(), + }; + } + + // eslint-disable-next-line require-yield + async *streamQuery() { + throw new Error('Not supported with SQLite'); + } +} diff --git a/packages/dialects/sql.js/src/dialect.ts b/packages/dialects/sql.js/src/dialect.ts new file mode 100644 index 00000000..922b08b0 --- /dev/null +++ b/packages/dialects/sql.js/src/dialect.ts @@ -0,0 +1,26 @@ +import type { Dialect } from 'kysely'; + +import type { SqlJsDialectConfig } from './types'; + +import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from 'kysely'; + +import { SqlJsDriver } from './driver'; + +/** + * The SqlJsDialect is for testing purposes only and should not be used in production. + */ +export class SqlJsDialect implements Dialect { + private config: SqlJsDialectConfig; + + constructor(config: SqlJsDialectConfig) { + this.config = config; + } + + createAdapter = () => new SqliteAdapter(); + + createDriver = () => new SqlJsDriver(this.config); + + createIntrospector = (db: Kysely) => new SqliteIntrospector(db); + + createQueryCompiler = () => new SqliteQueryCompiler(); +} diff --git a/packages/dialects/sql.js/src/driver.ts b/packages/dialects/sql.js/src/driver.ts new file mode 100644 index 00000000..b998d796 --- /dev/null +++ b/packages/dialects/sql.js/src/driver.ts @@ -0,0 +1,38 @@ +import type { DatabaseConnection, Driver } from 'kysely'; + +import { CompiledQuery } from 'kysely'; + +import { SqlJsConnection } from './connection'; +import type { SqlJsDialectConfig } from './types'; + +export class SqlJsDriver implements Driver { + private config: SqlJsDialectConfig; + + constructor(config: SqlJsDialectConfig) { + this.config = config; + } + + async acquireConnection(): Promise { + return new SqlJsConnection(this.config.sqlJs); + } + + async beginTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('BEGIN')); + } + + async commitTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('COMMIT')); + } + + async rollbackTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('ROLLBACK')); + } + + async destroy(): Promise { + this.config.sqlJs.close(); + } + + async init() {} + + async releaseConnection(_connection: DatabaseConnection): Promise {} +} diff --git a/packages/dialects/sql.js/src/index.ts b/packages/dialects/sql.js/src/index.ts new file mode 100644 index 00000000..096b4fc0 --- /dev/null +++ b/packages/dialects/sql.js/src/index.ts @@ -0,0 +1,4 @@ +export * from './connection'; +export * from './dialect'; +export * from './driver'; +export * from './types'; diff --git a/packages/dialects/sql.js/src/types.ts b/packages/dialects/sql.js/src/types.ts new file mode 100644 index 00000000..2405bb25 --- /dev/null +++ b/packages/dialects/sql.js/src/types.ts @@ -0,0 +1,5 @@ +import type { Database } from 'sql.js'; + +export interface SqlJsDialectConfig { + sqlJs: Database; +} diff --git a/packages/dialects/sql.js/test/getting-started/database.ts b/packages/dialects/sql.js/test/getting-started/database.ts new file mode 100644 index 00000000..7ab61248 --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/database.ts @@ -0,0 +1,12 @@ +import type { Database } from './types'; + +import { Kysely } from 'kysely'; +import initSqlJs from 'sql.js'; + +import { SqlJsDialect } from '../../src'; + +const SqlJsStatic = await initSqlJs(); + +export const db = new Kysely({ + dialect: new SqlJsDialect({ sqlJs: new SqlJsStatic.Database() }), +}); diff --git a/packages/dialects/sql.js/test/getting-started/person-repository.test.ts b/packages/dialects/sql.js/test/getting-started/person-repository.test.ts new file mode 100644 index 00000000..4d00eb1f --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/person-repository.test.ts @@ -0,0 +1,94 @@ +import { sql } from 'kysely'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { db } from './database'; +import * as PersonRepository from './person-repository'; + +describe('person-repository', () => { + beforeEach(async () => { + await db + .insertInto('person') + .values({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'other', + }) + .executeTakeFirstOrThrow(); + }); + + beforeAll(async () => { + await db.schema + .createTable('person') + .addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement().notNull()) + .addColumn('first_name', 'varchar(255)', (cb) => cb.notNull()) + .addColumn('last_name', 'varchar(255)') + .addColumn('gender', 'varchar(50)', (cb) => cb.notNull()) + .addColumn('created_at', 'timestamp', (cb) => cb.notNull().defaultTo(sql`current_timestamp`)) + .execute(); + }); + + afterEach(async () => { + await sql`delete from ${sql.table('person')}`.execute(db); + }); + + afterAll(async () => { + await db.schema.dropTable('person').execute(); + }); + + it('should find a person with a given id', async () => { + expect(await PersonRepository.findPersonById(123)).toMatchObject({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'other', + }); + }); + + it('should find all people named Arnold', async () => { + const people = await PersonRepository.findPeople({ first_name: 'Arnold' }); + + expect(people).toHaveLength(1); + expect(people[0]).toMatchObject({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'other', + }); + }); + + it('should update gender of a person with a given id', async () => { + await PersonRepository.updatePerson(123, { gender: 'woman' }); + + expect(await PersonRepository.findPersonById(123)).toMatchObject({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'woman', + }); + }); + + it('should create a person', async () => { + await PersonRepository.createPerson({ + first_name: 'Jennifer', + last_name: 'Aniston', + gender: 'woman', + }); + + expect(await PersonRepository.findPeople({ first_name: 'Jennifer' })).toHaveLength(1); + }); + + it('should create multiple persons', async () => { + const created = await PersonRepository.createPersons([ + { first_name: 'Brad', last_name: 'Pitt', gender: 'man' }, + { first_name: 'Angelina', last_name: 'Jolie', gender: 'woman' }, + ]); + await expect(PersonRepository.findPeople({ first_name: 'Brad' })).resolves.toBeTruthy(); + await expect(PersonRepository.findPeople({ first_name: 'Angelina' })).resolves.toBeTruthy(); + }); + + it('should delete a person with a given id', async () => { + await PersonRepository.deletePerson(123); + + expect(await PersonRepository.findPersonById(123)).toBeUndefined(); + }); +}); diff --git a/packages/dialects/sql.js/test/getting-started/person-repository.ts b/packages/dialects/sql.js/test/getting-started/person-repository.ts new file mode 100644 index 00000000..f062a816 --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/person-repository.ts @@ -0,0 +1,48 @@ +import type { PersonUpdate, Person, NewPerson } from './types'; + +import { db } from './database'; + +export async function findPersonById(id: number) { + return await db.selectFrom('person').where('id', '=', id).selectAll().executeTakeFirst(); +} + +export async function findPeople(criteria: Partial) { + let query = db.selectFrom('person'); + + if (criteria.id) { + query = query.where('id', '=', criteria.id); // Kysely is immutable, you must re-assign! + } + + if (criteria.first_name) { + query = query.where('first_name', '=', criteria.first_name); + } + + if (criteria.last_name !== undefined) { + query = query.where('last_name', criteria.last_name === null ? 'is' : '=', criteria.last_name); + } + + if (criteria.gender) { + query = query.where('gender', '=', criteria.gender); + } + + if (criteria.created_at) { + query = query.where('created_at', '=', criteria.created_at); + } + + return await query.selectAll().execute(); +} + +export async function updatePerson(id: number, updateWith: PersonUpdate) { + await db.updateTable('person').set(updateWith).where('id', '=', id).execute(); +} + +export async function createPerson(person: NewPerson) { + return await db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); +} + +export async function createPersons(persons: NewPerson[]) { + return await db.insertInto('person').values(persons).executeTakeFirstOrThrow(); +} +export async function deletePerson(id: number) { + return await db.deleteFrom('person').where('id', '=', id).returningAll().executeTakeFirst(); +} diff --git a/packages/dialects/sql.js/test/getting-started/types.ts b/packages/dialects/sql.js/test/getting-started/types.ts new file mode 100644 index 00000000..abe8bee2 --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/types.ts @@ -0,0 +1,53 @@ +import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely'; + +export interface Database { + person: PersonTable; + pet: PetTable; +} + +// This interface describes the `person` table to Kysely. Table +// interfaces should only be used in the `Database` type above +// and never as a result type of a query!. See the `Person`, +// `NewPerson` and `PersonUpdate` types below. +export interface PersonTable { + // Columns that are generated by the database should be marked + // using the `Generated` type. This way they are automatically + // made optional in inserts and updates. + id: Generated; + + first_name: string; + gender: 'man' | 'woman' | 'other'; + + // If the column is nullable in the database, make its type nullable. + // Don't use optional properties. Optionality is always determined + // automatically by Kysely. + last_name: string | null; + + // You can specify a different type for each operation (select, insert and + // update) using the `ColumnType` + // wrapper. Here we define a column `created_at` that is selected as + // a `Date`, can optionally be provided as a `string` in inserts and + // can never be updated: + created_at: ColumnType; +} + +// You should not use the table schema interfaces directly. Instead, you should +// use the `Selectable`, `Insertable` and `Updateable` wrappers. These wrappers +// make sure that the correct types are used in each operation. +// +// Most of the time you should trust the type inference and not use explicit +// types at all. These types can be useful when typing function arguments. +export type Person = Selectable; +export type NewPerson = Insertable; +export type PersonUpdate = Updateable; + +export interface PetTable { + id: Generated; + name: string; + owner_id: number; + species: 'dog' | 'cat'; +} + +export type Pet = Selectable; +export type NewPet = Insertable; +export type PetUpdate = Updateable; diff --git a/packages/dialects/sql.js/tsconfig.json b/packages/dialects/sql.js/tsconfig.json new file mode 100644 index 00000000..2125902f --- /dev/null +++ b/packages/dialects/sql.js/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/dialects/sql.js/tsup.config.ts b/packages/dialects/sql.js/tsup.config.ts new file mode 100644 index 00000000..5a74a9dd --- /dev/null +++ b/packages/dialects/sql.js/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + outDir: 'dist', + splitting: false, + sourcemap: true, + clean: true, + dts: true, + format: ['cjs', 'esm'], +}); diff --git a/packages/dialects/sql.js/vitest.config.ts b/packages/dialects/sql.js/vitest.config.ts new file mode 100644 index 00000000..23c01e72 --- /dev/null +++ b/packages/dialects/sql.js/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import base from '@zenstackhq/vitest-config/base'; + +export default mergeConfig(base, defineConfig({})); diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 35f52df2..e901c181 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.16", + "version": "3.0.0-alpha.17", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 54ed250f..a9c02ecb 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack", "publisher": "zenstack", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "displayName": "ZenStack Language Tools", "description": "VSCode extension for ZenStack ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 110852e0..f4cba5b4 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.16", + "version": "3.0.0-alpha.17", "license": "MIT", "author": "ZenStack Team", "files": [ @@ -62,6 +62,7 @@ "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/common-helpers": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", "langium-cli": "catalog:", "tmp": "catalog:", "@types/tmp": "catalog:" diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index fdd3b544..6b2cb56a 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -186,8 +186,12 @@ function linkContentToContainer(node: AstNode): void { function validationAfterImportMerge(model: Model) { const errors: string[] = []; const dataSources = model.declarations.filter((d) => isDataSource(d)); - if (dataSources.length > 1) { - errors.push('Validation error: Multiple datasource declarations are not allowed'); + if (dataSources.length === 0) { + errors.push('Validation error: schema must have a datasource declaration'); + } else { + if (dataSources.length > 1) { + errors.push('Validation error: multiple datasource declarations are not allowed'); + } } // at most one `@@auth` model diff --git a/packages/language/src/validators/datasource-validator.ts b/packages/language/src/validators/datasource-validator.ts index c2540426..84302785 100644 --- a/packages/language/src/validators/datasource-validator.ts +++ b/packages/language/src/validators/datasource-validator.ts @@ -41,24 +41,16 @@ export default class DataSourceValidator implements AstValidator { } private validateUrl(ds: DataSource, accept: ValidationAcceptor) { - const url = ds.fields.find((f) => f.name === 'url'); - if (!url) { - accept('error', 'datasource must include a "url" field', { - node: ds, - }); + const urlField = ds.fields.find((f) => f.name === 'url'); + if (!urlField) { + return; } - for (const fieldName of ['url', 'shadowDatabaseUrl']) { - const field = ds.fields.find((f) => f.name === fieldName); - if (!field) { - continue; - } - const value = getStringLiteral(field.value); - if (!value && !(isInvocationExpr(field.value) && field.value.function.ref?.name === 'env')) { - accept('error', `"${fieldName}" must be set to a string literal or an invocation of "env" function`, { - node: field.value, - }); - } + const value = getStringLiteral(urlField.value); + if (!value && !(isInvocationExpr(urlField.value) && urlField.value.function.ref?.name === 'env')) { + accept('error', `"${urlField.name}" must be set to a string literal or an invocation of "env" function`, { + node: urlField.value, + }); } } diff --git a/packages/language/vitest.config.ts b/packages/language/vitest.config.ts index 04655403..75a9f709 100644 --- a/packages/language/vitest.config.ts +++ b/packages/language/vitest.config.ts @@ -1,4 +1,4 @@ +import base from '@zenstackhq/vitest-config/base'; import { defineConfig, mergeConfig } from 'vitest/config'; -import base from '../../vitest.base.config'; export default mergeConfig(base, defineConfig({})); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 8faf7e9b..43d94fe0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "description": "ZenStack Runtime", "type": "module", "scripts": { @@ -96,6 +96,7 @@ "@zenstackhq/sdk": "workspace:*", "@zenstackhq/testtools": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", "tsx": "^4.19.2" } } diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index c9f6cc7c..ecf30fd7 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -1,6 +1,6 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import base from '../../vitest.base.config'; +import base from '@zenstackhq/vitest-config/base'; import path from 'node:path'; +import { defineConfig, mergeConfig } from 'vitest/config'; export default mergeConfig( base, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 200132b6..bf46d97d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "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 1f55db3e..dd8fb49d 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -559,32 +559,14 @@ export class TsSchemaGenerator { ); } - private getDataSourceProvider( - model: Model, - ): { type: string; env: undefined; url: string } | { type: string; env: string; url: undefined } { + private getDataSourceProvider(model: Model) { const dataSource = model.declarations.find(isDataSource); invariant(dataSource, 'No data source found in the model'); const providerExpr = dataSource.fields.find((f) => f.name === 'provider')?.value; invariant(isLiteralExpr(providerExpr), 'Provider must be a literal'); const type = providerExpr.value as string; - - const urlExpr = dataSource.fields.find((f) => f.name === 'url')?.value; - invariant(isLiteralExpr(urlExpr) || isInvocationExpr(urlExpr), 'URL must be a literal or env function'); - - if (isLiteralExpr(urlExpr)) { - return { type, url: urlExpr.value as string, env: undefined }; - } else if (isInvocationExpr(urlExpr)) { - invariant(urlExpr.function.$refText === 'env', 'only "env" function is supported'); - invariant(urlExpr.args.length === 1, 'env function must have one argument'); - return { - type, - env: (urlExpr.args[0]!.value as LiteralExpr).value as string, - url: undefined, - }; - } else { - throw new Error('Unsupported URL type'); - } + return { type }; } private getFieldMappedDefault( diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index 5732e3bc..7222447c 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.16", + "version": "3.0.0-alpha.17", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 6a111df5..a793cf80 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 1d7ae83c..d12bd8a1 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.16", + "version": "3.0.0-alpha.17", "private": true, "license": "MIT" } diff --git a/packages/vitest-config/base.config.js b/packages/vitest-config/base.config.js new file mode 100644 index 00000000..f7b36f73 --- /dev/null +++ b/packages/vitest-config/base.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + deps: { + interopDefault: true, + }, + include: ['**/*.test.ts'], + testTimeout: 100000, + hookTimeout: 100000, + }, +}); diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json new file mode 100644 index 00000000..77e04b45 --- /dev/null +++ b/packages/vitest-config/package.json @@ -0,0 +1,10 @@ +{ + "name": "@zenstackhq/vitest-config", + "type": "module", + "version": "3.0.0-alpha.17", + "private": true, + "license": "MIT", + "exports": { + "./base": "./base.config.js" + } +} diff --git a/packages/zod/package.json b/packages/zod/package.json index 33a31f94..9ad9a0be 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "description": "", "type": "module", "main": "index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9f0b6f7..3098cc6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@zenstackhq/typescript-config': specifier: workspace:* version: link:../typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../vitest-config better-sqlite3: specifier: ^11.8.1 version: 11.8.1 @@ -161,6 +164,27 @@ importers: specifier: workspace:* version: link:../typescript-config + packages/dialects/sql.js: + devDependencies: + '@types/sql.js': + specifier: ^1.4.9 + version: 1.4.9 + '@zenstackhq/eslint-config': + specifier: workspace:* + version: link:../../eslint-config + '@zenstackhq/typescript-config': + specifier: workspace:* + version: link:../../typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../vitest-config + kysely: + specifier: 'catalog:' + version: 0.27.6 + sql.js: + specifier: ^1.13.0 + version: 1.13.0 + packages/eslint-config: {} packages/ide/vscode: @@ -215,6 +239,9 @@ importers: '@zenstackhq/typescript-config': specifier: workspace:* version: link:../typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../vitest-config langium-cli: specifier: 'catalog:' version: 3.5.0 @@ -282,6 +309,9 @@ importers: '@zenstackhq/typescript-config': specifier: workspace:* version: link:../typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../vitest-config tsx: specifier: ^4.19.2 version: 4.19.2 @@ -375,6 +405,8 @@ importers: packages/typescript-config: {} + packages/vitest-config: {} + packages/zod: dependencies: '@zenstackhq/runtime': @@ -428,6 +460,9 @@ importers: '@zenstackhq/cli': specifier: workspace:* version: link:../../packages/cli + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../packages/vitest-config packages: @@ -1060,6 +1095,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/emscripten@1.40.1': + resolution: {integrity: sha512-sr53lnYkQNhjHNN0oJDdUm5564biioI5DuOpycufDVK7D3y+GR3oUswe2rlwY1nPNyusHbrJ9WoTyIHl4/Bpwg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1075,6 +1113,9 @@ packages: '@types/pluralize@0.0.33': resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/sql.js@1.4.9': + resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==} + '@types/tmp@0.2.6': resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} @@ -2173,6 +2214,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sql.js@1.13.0: + resolution: {integrity: sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2969,6 +3013,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/emscripten@1.40.1': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -2985,6 +3031,11 @@ snapshots: '@types/pluralize@0.0.33': {} + '@types/sql.js@1.4.9': + dependencies: + '@types/emscripten': 1.40.1 + '@types/node': 20.17.24 + '@types/tmp@0.2.6': {} '@types/vscode@1.101.0': {} @@ -4137,6 +4188,8 @@ snapshots: split2@4.2.0: {} + sql.js@1.13.0: {} + stackback@0.0.2: {} std-env@3.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c30abb5..de6cfe1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,5 @@ packages: - packages/** - - packages/ide/** - samples/** - tests/** catalog: diff --git a/samples/blog/package.json b/samples/blog/package.json index 8cdb8422..ab4329b1 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "description": "", "main": "index.js", "scripts": { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index d3bcf2bc..39aebde4 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,7 +1,8 @@ { "name": "e2e", - "version": "3.0.0-alpha.16", + "version": "3.0.0-alpha.17", "private": true, + "type": "module", "scripts": { "test": "vitest run" }, @@ -9,6 +10,7 @@ "@zenstackhq/testtools": "workspace:*" }, "devDependencies": { - "@zenstackhq/cli": "workspace:*" + "@zenstackhq/cli": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*" } } diff --git a/tests/e2e/prisma-consistency/datasource.test.ts b/tests/e2e/prisma-consistency/datasource.test.ts index 2c02c0de..123feb84 100644 --- a/tests/e2e/prisma-consistency/datasource.test.ts +++ b/tests/e2e/prisma-consistency/datasource.test.ts @@ -1,11 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - ZenStackValidationTester, - createTestDir, - expectValidationSuccess, - expectValidationFailure, - baseSchema, -} from './test-utils'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationFailure } from './test-utils'; describe('Datasource Validation', () => { let tester: ZenStackValidationTester; diff --git a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts index 506fc2d4..ca52d84c 100644 --- a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts +++ b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts @@ -1,10 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, it } from 'vitest'; import { ZenStackValidationTester, + baseSchema, createTestDir, - expectValidationSuccess, expectValidationFailure, - baseSchema, + expectValidationSuccess, } from './test-utils'; describe('One-to-Many Relations Validation', () => { diff --git a/tests/e2e/prisma-consistency/test-utils.ts b/tests/e2e/prisma-consistency/test-utils.ts index ba6ba3a2..5a53fead 100644 --- a/tests/e2e/prisma-consistency/test-utils.ts +++ b/tests/e2e/prisma-consistency/test-utils.ts @@ -8,7 +8,6 @@ import { expect } from 'vitest'; export interface ValidationResult { success: boolean; - errors: string[]; } export class ZenStackValidationTester { @@ -60,29 +59,14 @@ export class ZenStackValidationTester { return { success: true, - errors: [], }; } catch (error: any) { return { success: false, - errors: this.extractErrors(error.stderr), }; } } - private extractErrors(output: string): string[] { - const lines = output.split('\n'); - const errors: string[] = []; - - for (const line of lines) { - if (line.includes('Error:') || line.includes('error:') || line.includes('✖')) { - errors.push(line.trim()); - } - } - - return errors; - } - public cleanup() { if (existsSync(this.testDir)) { rmSync(this.testDir, { recursive: true, force: true }); @@ -100,7 +84,6 @@ export function expectValidationSuccess(result: ValidationResult) { export function expectValidationFailure(result: ValidationResult) { expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); } export const baseSchema = ` diff --git a/tests/e2e/vitest.config.ts b/tests/e2e/vitest.config.ts index 04655403..75a9f709 100644 --- a/tests/e2e/vitest.config.ts +++ b/tests/e2e/vitest.config.ts @@ -1,4 +1,4 @@ +import base from '@zenstackhq/vitest-config/base'; import { defineConfig, mergeConfig } from 'vitest/config'; -import base from '../../vitest.base.config'; export default mergeConfig(base, defineConfig({}));