diff --git a/README.md b/README.md index 5e2f5e65..c2881d7c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Even without using advanced features, ZenStack offers the following benefits as # Get started +> You can also check the [blog sample](./samples/blog) for a complete example. + ## Installation ### 1. Creating a new project @@ -136,7 +138,9 @@ Now you can use the compiled TypeScript schema to instantiate a database client: import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './zenstack/schema'; -const client = new ZenStackClient(schema); +const client = new ZenStackClient(schema, { + dialectConfig: { ... } +}); ``` ## Using `ZenStackClient` @@ -207,7 +211,7 @@ ZenStack v3 allows you to define database-evaluated computed fields with the fol model User { ... /// number of posts owned by the user - postCount Int @computed + postCount Int @computed } ``` @@ -215,6 +219,7 @@ ZenStack v3 allows you to define database-evaluated computed fields with the fol ```ts const client = new ZenStackClient(schema, { + ... computedFields: { User: { postCount: (eb) => @@ -367,7 +372,7 @@ See [Prisma Migrate](https://www.prisma.io/docs/orm/prisma-migrate) documentatio 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. Replace `new PrismaClient()` with `new ZenStackClient(schema)` +1. Replace `new PrismaClient()` with `new ZenStackClient(schema, { ... })` # Limitations diff --git a/TODO.md b/TODO.md index bce001b6..ffaf68d7 100644 --- a/TODO.md +++ b/TODO.md @@ -66,6 +66,7 @@ - [x] Custom table name - [x] Custom field name - [ ] Strict undefined check + - [ ] Implement changesets - [ ] Polymorphism - [ ] Validation - [ ] Access Policy diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index f6dcdaf6..c7c61adc 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -40,7 +40,9 @@ export async function run(options: Options) { import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from '${outputPath}/schema'; -const client = new ZenStackClient(schema); +const client = new ZenStackClient(schema, { + dialectConfig: { ... } +}); \`\`\` `); } diff --git a/packages/cli/src/actions/templates.ts b/packages/cli/src/actions/templates.ts index e8b20729..1909ff42 100644 --- a/packages/cli/src/actions/templates.ts +++ b/packages/cli/src/actions/templates.ts @@ -28,9 +28,14 @@ model Post { export const STARTER_MAIN_TS = `import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './zenstack/schema'; +import SQLite from 'better-sqlite3'; async function main() { - const client = new ZenStackClient(schema); + const client = new ZenStackClient(schema, { + dialectConfig: { + database: new SQLite('./zenstack/dev.db'), + }, + }); const user = await client.user.create({ data: { email: 'test@zenstack.dev', diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index c8fdad96..48692d9a 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -27,7 +27,6 @@ model Post { expect(schema.provider).toMatchObject({ type: 'sqlite', - dialectConfigProvider: expect.any(Function), }); expect(schema.models).toMatchObject({ diff --git a/packages/create-zenstack/src/index.ts b/packages/create-zenstack/src/index.ts index abee5da8..fb6db2f9 100644 --- a/packages/create-zenstack/src/index.ts +++ b/packages/create-zenstack/src/index.ts @@ -9,22 +9,18 @@ import { STARTER_MAIN_TS, STARTER_ZMODEL } from './templates'; const npmAgent = process.env['npm_config_user_agent']; let agent = 'npm'; let agentExec = 'npx'; -let initCommand = 'npm init -y'; let saveDev = '--save-dev'; if (npmAgent?.includes('pnpm')) { agent = 'pnpm'; - initCommand = 'pnpm init'; agentExec = 'pnpm'; } else if (npmAgent?.includes('yarn')) { agent = 'yarn'; - initCommand = 'yarn init'; agentExec = 'npx'; saveDev = '--dev'; } else if (npmAgent?.includes('bun')) { agent = 'bun'; agentExec = 'bun'; - initCommand = 'bun init -y -m'; } const program = new Command('create-zenstack'); @@ -46,20 +42,60 @@ function initProject(name: string) { console.log(colors.gray(`Using package manager: ${agent}`)); - // initialize project - execSync(initCommand); + // create package.json + fs.writeFileSync( + 'package.json', + JSON.stringify( + { + name: 'zenstack-app', + version: '1.0.0', + description: 'Scaffolded with create-zenstack', + type: 'module', + scripts: { + dev: 'tsx main.ts', + }, + license: 'ISC', + }, + null, + 2 + ) + ); // install packages const packages = [ { name: '@zenstackhq/cli@next', dev: true }, { name: '@zenstackhq/runtime@next', dev: false }, { name: 'better-sqlite3', dev: false }, + { name: '@types/better-sqlite3', dev: true }, + { name: 'typescript', dev: true }, { name: 'tsx', dev: true }, + { name: '@types/node', dev: true }, ]; for (const pkg of packages) { installPackage(pkg); } + // create tsconfig.json + fs.writeFileSync( + 'tsconfig.json', + JSON.stringify( + { + compilerOptions: { + module: 'esnext', + target: 'esnext', + moduleResolution: 'bundler', + sourceMap: true, + outDir: 'dist', + strict: true, + skipLibCheck: true, + esModuleInterop: true, + }, + }, + null, + 2 + ) + ); + // create schema.zmodel fs.mkdirSync('zenstack'); fs.writeFileSync('zenstack/schema.zmodel', STARTER_ZMODEL); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1ce88f1c..f7860645 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -59,26 +59,6 @@ "default": "./dist/plugins/policy.cjs" } }, - "./utils/pg-utils": { - "import": { - "types": "./dist/utils/pg-utils.d.ts", - "default": "./dist/utils/pg-utils.js" - }, - "require": { - "types": "./dist/utils/pg-utils.d.cts", - "default": "./dist/utils/pg-utils.cjs" - } - }, - "./utils/sqlite-utils": { - "import": { - "types": "./dist/utils/sqlite-utils.d.ts", - "default": "./dist/utils/sqlite-utils.js" - }, - "require": { - "types": "./dist/utils/sqlite-utils.d.cts", - "default": "./dist/utils/sqlite-utils.cjs" - } - }, "./package.json": { "import": "./package.json", "require": "./package.json" @@ -91,7 +71,6 @@ "json-stable-stringify": "^1.3.0", "kysely": "^0.27.5", "nanoid": "^5.0.9", - "pg-connection-string": "^2.9.0", "tiny-invariant": "^1.3.3", "ts-pattern": "^5.6.0", "ulid": "^3.0.0", diff --git a/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index 2778ff89..84307e1f 100644 --- a/packages/runtime/src/client/client-impl.ts +++ b/packages/runtime/src/client/client-impl.ts @@ -1,3 +1,4 @@ +import type { SqliteDialectConfig } from 'kysely'; import { DefaultConnectionProvider, DefaultQueryExecutor, @@ -7,7 +8,6 @@ import { SqliteDialect, type KyselyProps, type PostgresDialectConfig, - type SqliteDialectConfig, } from 'kysely'; import { match } from 'ts-pattern'; import type { GetModels, ProcedureDef, SchemaDef } from '../schema'; @@ -41,7 +41,7 @@ import { ResultProcessor } from './result-processor'; export const ZenStackClient = function ( this: any, schema: any, - options?: ClientOptions + options: ClientOptions ) { return new ClientImpl(schema, options); } as unknown as ClientConstructor; @@ -56,7 +56,7 @@ export class ClientImpl { constructor( private readonly schema: Schema, - private options?: ClientOptions, + private options: ClientOptions, baseClient?: ClientImpl ) { this.$schema = schema; @@ -140,21 +140,15 @@ export class ClientImpl { } private makePostgresKyselyDialect(): PostgresDialect { - const { dialectConfigProvider } = this.schema.provider; - const mergedConfig = { - ...dialectConfigProvider(), - ...this.options?.dialectConfig, - } as PostgresDialectConfig; - return new PostgresDialect(mergedConfig); + return new PostgresDialect( + this.options.dialectConfig as PostgresDialectConfig + ); } private makeSqliteKyselyDialect(): SqliteDialect { - const { dialectConfigProvider } = this.schema.provider; - const mergedConfig = { - ...dialectConfigProvider(), - ...this.options?.dialectConfig, - } as SqliteDialectConfig; - return new SqliteDialect(mergedConfig); + return new SqliteDialect( + this.options.dialectConfig as SqliteDialectConfig + ); } async $transaction( diff --git a/packages/runtime/src/client/options.ts b/packages/runtime/src/client/options.ts index 01ce30ea..6300d86e 100644 --- a/packages/runtime/src/client/options.ts +++ b/packages/runtime/src/client/options.ts @@ -47,7 +47,7 @@ export type ClientOptions = { /** * Database dialect configuration. */ - dialectConfig?: DialectConfig; + dialectConfig: DialectConfig; /** * Custom function definitions. diff --git a/packages/runtime/src/utils/pg-utils.ts b/packages/runtime/src/utils/pg-utils.ts deleted file mode 100644 index f4dd33eb..00000000 --- a/packages/runtime/src/utils/pg-utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { PostgresDialectConfig } from 'kysely'; -import { Pool } from 'pg'; -import { parseIntoClientConfig } from 'pg-connection-string'; - -/** - * Convert a PostgreSQL connection string to a Kysely dialect config. - */ -export function toDialectConfig(url: string): PostgresDialectConfig { - return { - pool: new Pool(parseIntoClientConfig(url)), - }; -} diff --git a/packages/runtime/src/utils/sqlite-utils.ts b/packages/runtime/src/utils/sqlite-utils.ts deleted file mode 100644 index 407bdcb1..00000000 --- a/packages/runtime/src/utils/sqlite-utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import SQLite from 'better-sqlite3'; -import type { SqliteDialectConfig } from 'kysely'; -import path from 'node:path'; - -/** - * Convert a SQLite connection string to a Kysely dialect config. - */ -export function toDialectConfig( - url: string, - baseDir: string -): SqliteDialectConfig { - if (url === ':memory:') { - return { - database: new SQLite(':memory:'), - }; - } - const filePath = path.resolve(baseDir, url); - return { - database: new SQLite(filePath), - }; -} diff --git a/packages/runtime/test/client-api/default-values.test.ts b/packages/runtime/test/client-api/default-values.test.ts index da57fc84..f36b2c94 100644 --- a/packages/runtime/test/client-api/default-values.test.ts +++ b/packages/runtime/test/client-api/default-values.test.ts @@ -9,10 +9,6 @@ import { ExpressionUtils, type SchemaDef } from '../../src/schema'; const schema = { provider: { type: 'sqlite', - dialectConfigProvider: () => - ({ - database: new SQLite(':memory:'), - } as any), }, models: { Model: { @@ -68,7 +64,9 @@ const schema = { describe('default values tests', () => { it('supports generators', async () => { - const client = new ZenStackClient(schema); + const client = new ZenStackClient(schema, { + dialectConfig: { database: new SQLite(':memory:') }, + }); await client.$pushSchema(); const entity = await client.model.create({ data: {} }); diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index bd85e05e..7c7ca42d 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -7,9 +7,6 @@ describe('Name mapping tests', () => { const schema = { provider: { type: 'sqlite', - dialectConfigProvider: () => ({ - database: new SQLite(':memory:'), - }), }, models: { Foo: { @@ -58,7 +55,9 @@ describe('Name mapping tests', () => { } as const satisfies SchemaDef; it('works with model and implicit field mapping', async () => { - const client = new ZenStackClient(schema); + const client = new ZenStackClient(schema, { + dialectConfig: { database: new SQLite(':memory:') }, + }); await client.$pushSchema(); const r1 = await client.foo.create({ data: { id: '1', x: 1 }, @@ -88,7 +87,9 @@ describe('Name mapping tests', () => { }); it('works with explicit field mapping', async () => { - const client = new ZenStackClient(schema); + const client = new ZenStackClient(schema, { + dialectConfig: { database: new SQLite(':memory:') }, + }); await client.$pushSchema(); const r1 = await client.foo.create({ data: { id: '1', x: 1 }, diff --git a/packages/runtime/test/plugin/kysely-on-query.test.ts b/packages/runtime/test/plugin/kysely-on-query.test.ts index 736a6dc8..8aec526d 100644 --- a/packages/runtime/test/plugin/kysely-on-query.test.ts +++ b/packages/runtime/test/plugin/kysely-on-query.test.ts @@ -1,3 +1,4 @@ +import SQLite from 'better-sqlite3'; import { InsertQueryNode, Kysely, @@ -13,7 +14,9 @@ describe('Kysely onQuery tests', () => { let _client: ClientContract; beforeEach(async () => { - _client = new ZenStackClient(schema); + _client = new ZenStackClient(schema, { + dialectConfig: { database: new SQLite(':memory:') }, + }); await _client.$pushSchema(); }); @@ -21,11 +24,11 @@ describe('Kysely onQuery tests', () => { let called = false; const client = _client.$use({ id: 'test-plugin', - onKyselyQuery(args) { - if (args.query.kind === 'InsertQueryNode') { + onKyselyQuery({ query, proceed }) { + if (query.kind === 'InsertQueryNode') { called = true; } - return args.proceed(args.query); + return proceed(query); }, }); await expect( diff --git a/packages/runtime/test/plugin/mutation-hooks.test.ts b/packages/runtime/test/plugin/mutation-hooks.test.ts index 790b03cb..43877c22 100644 --- a/packages/runtime/test/plugin/mutation-hooks.test.ts +++ b/packages/runtime/test/plugin/mutation-hooks.test.ts @@ -1,13 +1,18 @@ +import SQLite from 'better-sqlite3'; import { DeleteQueryNode, InsertQueryNode, UpdateQueryNode } from 'kysely'; import { beforeEach, describe, expect, it } from 'vitest'; import { ZenStackClient, type ClientContract } from '../../src'; import { schema } from '../test-schema'; -describe('Entity lifecycle tests', () => { +describe('Entity lifecycle tests', () => { let _client: ClientContract; beforeEach(async () => { - _client = await new ZenStackClient(schema); + _client = new ZenStackClient(schema, { + dialectConfig: { + database: new SQLite(':memory:'), + }, + }); await _client.$pushSchema(); }); diff --git a/packages/runtime/test/plugin/query-lifecycle.test.ts b/packages/runtime/test/plugin/query-lifecycle.test.ts index ea55c906..19858431 100644 --- a/packages/runtime/test/plugin/query-lifecycle.test.ts +++ b/packages/runtime/test/plugin/query-lifecycle.test.ts @@ -1,3 +1,4 @@ +import SQLite from 'better-sqlite3'; import { beforeEach, describe, expect, it } from 'vitest'; import { ZenStackClient, type ClientContract } from '../../src/client'; import { schema } from '../test-schema'; @@ -6,7 +7,11 @@ describe('Query interception tests', () => { let _client: ClientContract; beforeEach(async () => { - _client = await new ZenStackClient(schema); + _client = new ZenStackClient(schema, { + dialectConfig: { + database: new SQLite(':memory:'), + }, + }); await _client.$pushSchema(); }); diff --git a/packages/runtime/test/policy/utils.ts b/packages/runtime/test/policy/utils.ts index 2c63da5c..d6b1192b 100644 --- a/packages/runtime/test/policy/utils.ts +++ b/packages/runtime/test/policy/utils.ts @@ -6,8 +6,11 @@ export function createPolicyTestClient( schema: string | SchemaDef, options?: CreateTestClientOptions ) { - return createTestClient(schema as any, { - ...options, - plugins: [new PolicyPlugin()], - }); + return createTestClient( + schema as any, + { + ...options, + plugins: [new PolicyPlugin()], + } as CreateTestClientOptions + ); } diff --git a/packages/runtime/test/test-schema.ts b/packages/runtime/test/test-schema.ts index 65bea2b1..da5e4a58 100644 --- a/packages/runtime/test/test-schema.ts +++ b/packages/runtime/test/test-schema.ts @@ -1,4 +1,3 @@ -import Sqlite from 'better-sqlite3'; import { ExpressionUtils, type DataSourceProviderType, @@ -8,10 +7,6 @@ import { export const schema = { provider: { type: 'sqlite', - dialectConfigProvider: () => - ({ - database: new Sqlite(':memory:'), - } as object), }, models: { User: { @@ -339,7 +334,6 @@ export function getSchema( ...schema, provider: { type, - dialectConfigProvider: () => ({}), }, }; } diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/typing/schema.ts index 63ba6c92..c116297b 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/typing/schema.ts @@ -3,249 +3,416 @@ // This file is automatically generated by ZenStack CLI and should not be manually updated. // ////////////////////////////////////////////////////////////////////////////////////////////// -import { type SchemaDef, type OperandExpression, ExpressionUtils } from "../../dist/schema"; -import path from "node:path"; -import url from "node:url"; -import { toDialectConfig } from "../../dist/utils/sqlite-utils"; +import { + type OperandExpression, + type SchemaDef, + ExpressionUtils, +} from '../../dist/schema'; export const schema = { provider: { - type: "sqlite", - dialectConfigProvider: function () { - return toDialectConfig("./test.db", typeof __dirname !== 'undefined' ? __dirname : path.dirname(url.fileURLToPath(import.meta.url))); - } + type: 'sqlite', }, models: { User: { fields: { id: { - type: "Int", + type: 'Int', id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [ + { name: '@id' }, + { + name: '@default', + args: [ + { + name: 'value', + value: ExpressionUtils.call( + 'autoincrement' + ), + }, + ], + }, + ], + default: ExpressionUtils.call('autoincrement'), }, createdAt: { - type: "DateTime", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], - default: ExpressionUtils.call("now") + type: 'DateTime', + attributes: [ + { + name: '@default', + args: [ + { + name: 'value', + value: ExpressionUtils.call('now'), + }, + ], + }, + ], + default: ExpressionUtils.call('now'), }, updatedAt: { - type: "DateTime", + type: 'DateTime', updatedAt: true, - attributes: [{ name: "@updatedAt" }] + attributes: [{ name: '@updatedAt' }], }, name: { - type: "String" + type: 'String', }, email: { - type: "String", + type: 'String', unique: true, - attributes: [{ name: "@unique" }] + attributes: [{ name: '@unique' }], }, posts: { - type: "Post", + type: 'Post', array: true, - relation: { opposite: "author" } + relation: { opposite: 'author' }, }, profile: { - type: "Profile", + type: 'Profile', optional: true, - relation: { opposite: "user" } + relation: { opposite: 'user' }, }, postCount: { - type: "Int", - attributes: [{ name: "@computed" }], - computed: true - } + type: 'Int', + attributes: [{ name: '@computed' }], + computed: true, + }, }, - idFields: ["id"], + idFields: ['id'], uniqueFields: { - id: { type: "Int" }, - email: { type: "String" } + id: { type: 'Int' }, + email: { type: 'String' }, }, computedFields: { postCount(): OperandExpression { - throw new Error("This is a stub for computed field"); - } - } + throw new Error('This is a stub for computed field'); + }, + }, }, Post: { fields: { id: { - type: "Int", + type: 'Int', id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [ + { name: '@id' }, + { + name: '@default', + args: [ + { + name: 'value', + value: ExpressionUtils.call( + 'autoincrement' + ), + }, + ], + }, + ], + default: ExpressionUtils.call('autoincrement'), }, title: { - type: "String" + type: 'String', }, content: { - type: "String" + type: 'String', }, author: { - type: "User", - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], - relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + type: 'User', + attributes: [ + { + name: '@relation', + args: [ + { + name: 'fields', + value: ExpressionUtils.array([ + ExpressionUtils.field('authorId'), + ]), + }, + { + name: 'references', + value: ExpressionUtils.array([ + ExpressionUtils.field('id'), + ]), + }, + ], + }, + ], + relation: { + opposite: 'posts', + fields: ['authorId'], + references: ['id'], + }, }, authorId: { - type: "Int", - foreignKeyFor: [ - "author" - ] + type: 'Int', + foreignKeyFor: ['author'], }, tags: { - type: "Tag", + type: 'Tag', array: true, - relation: { opposite: "posts" } + relation: { opposite: 'posts' }, }, meta: { - type: "Meta", + type: 'Meta', optional: true, - relation: { opposite: "post" } - } + relation: { opposite: 'post' }, + }, }, - idFields: ["id"], + idFields: ['id'], uniqueFields: { - id: { type: "Int" } - } + id: { type: 'Int' }, + }, }, Profile: { fields: { id: { - type: "Int", + type: 'Int', id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [ + { name: '@id' }, + { + name: '@default', + args: [ + { + name: 'value', + value: ExpressionUtils.call( + 'autoincrement' + ), + }, + ], + }, + ], + default: ExpressionUtils.call('autoincrement'), }, age: { - type: "Int" + type: 'Int', }, region: { - type: "Region", + type: 'Region', optional: true, - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("regionCountry"), ExpressionUtils.field("regionCity")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("country"), ExpressionUtils.field("city")]) }] }], - relation: { opposite: "profiles", fields: ["regionCountry", "regionCity"], references: ["country", "city"] } + attributes: [ + { + name: '@relation', + args: [ + { + name: 'fields', + value: ExpressionUtils.array([ + ExpressionUtils.field('regionCountry'), + ExpressionUtils.field('regionCity'), + ]), + }, + { + name: 'references', + value: ExpressionUtils.array([ + ExpressionUtils.field('country'), + ExpressionUtils.field('city'), + ]), + }, + ], + }, + ], + relation: { + opposite: 'profiles', + fields: ['regionCountry', 'regionCity'], + references: ['country', 'city'], + }, }, regionCountry: { - type: "String", + type: 'String', optional: true, - foreignKeyFor: [ - "region" - ] + foreignKeyFor: ['region'], }, regionCity: { - type: "String", + type: 'String', optional: true, - foreignKeyFor: [ - "region" - ] + foreignKeyFor: ['region'], }, user: { - type: "User", - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], - relation: { opposite: "profile", fields: ["userId"], references: ["id"] } + type: 'User', + attributes: [ + { + name: '@relation', + args: [ + { + name: 'fields', + value: ExpressionUtils.array([ + ExpressionUtils.field('userId'), + ]), + }, + { + name: 'references', + value: ExpressionUtils.array([ + ExpressionUtils.field('id'), + ]), + }, + ], + }, + ], + relation: { + opposite: 'profile', + fields: ['userId'], + references: ['id'], + }, }, userId: { - type: "Int", + type: 'Int', unique: true, - attributes: [{ name: "@unique" }], - foreignKeyFor: [ - "user" - ] - } + attributes: [{ name: '@unique' }], + foreignKeyFor: ['user'], + }, }, - idFields: ["id"], + idFields: ['id'], uniqueFields: { - id: { type: "Int" }, - userId: { type: "Int" } - } + id: { type: 'Int' }, + userId: { type: 'Int' }, + }, }, Tag: { fields: { id: { - type: "Int", + type: 'Int', id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [ + { name: '@id' }, + { + name: '@default', + args: [ + { + name: 'value', + value: ExpressionUtils.call( + 'autoincrement' + ), + }, + ], + }, + ], + default: ExpressionUtils.call('autoincrement'), }, name: { - type: "String" + type: 'String', }, posts: { - type: "Post", + type: 'Post', array: true, - relation: { opposite: "tags" } - } + relation: { opposite: 'tags' }, + }, }, - idFields: ["id"], + idFields: ['id'], uniqueFields: { - id: { type: "Int" } - } + id: { type: 'Int' }, + }, }, Region: { fields: { country: { - type: "String", - id: true + type: 'String', + id: true, }, city: { - type: "String", - id: true + type: 'String', + id: true, }, zip: { - type: "String", - optional: true + type: 'String', + optional: true, }, profiles: { - type: "Profile", + type: 'Profile', array: true, - relation: { opposite: "region" } - } + relation: { opposite: 'region' }, + }, }, attributes: [ - { name: "@@id", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("country"), ExpressionUtils.field("city")]) }] } + { + name: '@@id', + args: [ + { + name: 'fields', + value: ExpressionUtils.array([ + ExpressionUtils.field('country'), + ExpressionUtils.field('city'), + ]), + }, + ], + }, ], - idFields: ["country", "city"], + idFields: ['country', 'city'], uniqueFields: { - country_city: { country: { type: "String" }, city: { type: "String" } } - } + country_city: { + country: { type: 'String' }, + city: { type: 'String' }, + }, + }, }, Meta: { fields: { id: { - type: "Int", + type: 'Int', id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [ + { name: '@id' }, + { + name: '@default', + args: [ + { + name: 'value', + value: ExpressionUtils.call( + 'autoincrement' + ), + }, + ], + }, + ], + default: ExpressionUtils.call('autoincrement'), }, reviewed: { - type: "Boolean" + type: 'Boolean', }, published: { - type: "Boolean" + type: 'Boolean', }, post: { - type: "Post", - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("postId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], - relation: { opposite: "meta", fields: ["postId"], references: ["id"] } + type: 'Post', + attributes: [ + { + name: '@relation', + args: [ + { + name: 'fields', + value: ExpressionUtils.array([ + ExpressionUtils.field('postId'), + ]), + }, + { + name: 'references', + value: ExpressionUtils.array([ + ExpressionUtils.field('id'), + ]), + }, + ], + }, + ], + relation: { + opposite: 'meta', + fields: ['postId'], + references: ['id'], + }, }, postId: { - type: "Int", + type: 'Int', unique: true, - attributes: [{ name: "@unique" }], - foreignKeyFor: [ - "post" - ] - } + attributes: [{ name: '@unique' }], + foreignKeyFor: ['post'], + }, }, - idFields: ["id"], + idFields: ['id'], uniqueFields: { - id: { type: "Int" }, - postId: { type: "Int" } - } - } + id: { type: 'Int' }, + postId: { type: 'Int' }, + }, + }, }, - authType: "User", - plugins: {} + authType: 'User', + plugins: {}, } as const satisfies SchemaDef; export type SchemaType = typeof schema; diff --git a/packages/runtime/test/typing/verify-typing.ts b/packages/runtime/test/typing/verify-typing.ts index 7fa91666..e292d69c 100644 --- a/packages/runtime/test/typing/verify-typing.ts +++ b/packages/runtime/test/typing/verify-typing.ts @@ -1,7 +1,11 @@ import { ZenStackClient } from '../../dist'; import { schema } from './schema'; +import SQLite from 'better-sqlite3'; const client = new ZenStackClient(schema, { + dialectConfig: { + database: new SQLite('./zenstack/test.db'), + }, computedFields: { User: { postCount: (eb) => diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index d1e00a6a..86f1b5ed 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -1,7 +1,7 @@ import { loadDocument } from '@zenstackhq/language'; import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; import { generateTsSchema } from '@zenstackhq/testtools'; -import Sqlite from 'better-sqlite3'; +import SQLite from 'better-sqlite3'; import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -20,7 +20,7 @@ export async function makeSqliteClient( ) { const client = new ZenStackClient(schema, { ...extraOptions, - dialectConfig: { database: new Sqlite(':memory:') }, + dialectConfig: { database: new SQLite(':memory:') }, } as unknown as ClientOptions); await client.$pushSchema(); return client; @@ -59,12 +59,14 @@ export async function makePostgresClient( return client; } -export type CreateTestClientOptions = - ClientOptions & { - provider?: 'sqlite' | 'postgresql'; - dbName?: string; - usePrismaPush?: boolean; - }; +export type CreateTestClientOptions = Omit< + ClientOptions, + 'dialectConfig' +> & { + provider?: 'sqlite' | 'postgresql'; + dbName?: string; + usePrismaPush?: boolean; +}; export async function createTestClient( schema: Schema, @@ -93,6 +95,11 @@ export async function createTestClient( _schema = schema; } + const { plugins, ...rest } = options ?? {}; + const _options: ClientOptions = { + ...rest, + } as ClientOptions; + if (options?.usePrismaPush) { invariant(typeof schema === 'string', 'schema must be a string'); invariant(workDir, 'workDir is required'); @@ -126,19 +133,49 @@ export async function createTestClient( } } - const { plugins, usePrismaPush, ...rest } = options ?? {}; + if (options?.provider === 'postgresql') { + _options.dialectConfig = { + pool: new Pool({ + ...TEST_PG_CONFIG, + database: options!.dbName, + }), + } as unknown as ClientOptions['dialectConfig']; + } else { + _options.dialectConfig = { + database: new SQLite( + options?.usePrismaPush + ? getDbPath(path.join(workDir!, 'schema.prisma')) + : ':memory:' + ), + } as unknown as ClientOptions['dialectConfig']; + } - let client = new ZenStackClient(_schema, rest as ClientOptions); + let client = new ZenStackClient(_schema, _options); - if (!usePrismaPush) { + if (!options?.usePrismaPush) { await client.$pushSchema(); } - if (options?.plugins) { - for (const plugin of options.plugins) { + if (plugins) { + for (const plugin of plugins) { client = client.$use(plugin); } } return client; } + +function getDbPath(prismaSchemaPath: string) { + const content = fs.readFileSync(prismaSchemaPath, 'utf-8'); + const found = content.match(/^\s*url\s*=(\s|")*([^"]+)(\s|")*$/m); + if (!found) { + throw new Error('No url found in prisma schema'); + } + const dbPath = found[2]!; + // convert 'file:./dev.db' to './dev.db' + const r = path.join( + path.dirname(prismaSchemaPath), + dbPath.replace(/^file:/, '') + ); + return r; +} diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index cfc1ffe7..114183c4 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -6,8 +6,6 @@ export default defineConfig({ client: 'src/client/index.ts', schema: 'src/schema/index.ts', 'plugins/policy': 'src/plugins/policy/index.ts', - 'utils/pg-utils': 'src/utils/pg-utils.ts', - 'utils/sqlite-utils': 'src/utils/sqlite-utils.ts', }, outDir: 'dist', splitting: false, diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index 9b62ca20..9aa89dd6 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -5,7 +5,6 @@ export type DataSourceProviderType = 'sqlite' | 'postgresql'; export type DataSourceProvider = { type: DataSourceProviderType; - dialectConfigProvider: () => object; }; export type SchemaDef = { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index a90bb8d9..ac7caf23 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -124,87 +124,6 @@ export class TsSchemaGenerator { ); statements.push(runtimeImportDecl); - const { type: providerType } = this.getDataSourceProvider(model); - switch (providerType) { - case 'sqlite': { - // add imports for calculating the path of sqlite database file - - // `import path from 'node:path';` - const pathImportDecl = ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - ts.factory.createIdentifier('path'), - undefined - ), - ts.factory.createStringLiteral('node:path') - ); - statements.push(pathImportDecl); - - // `import url from 'node:url';` - const urlImportDecl = ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - ts.factory.createIdentifier('url'), - undefined - ), - ts.factory.createStringLiteral('node:url') - ); - statements.push(urlImportDecl); - - // `import { toDialectConfig } from '@zenstackhq/runtime/utils/sqlite-utils';` - const dialectConfigImportDecl = - ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamedImports([ - ts.factory.createImportSpecifier( - false, - undefined, - ts.factory.createIdentifier( - 'toDialectConfig' - ) - ), - ]) - ), - ts.factory.createStringLiteral( - '@zenstackhq/runtime/utils/sqlite-utils' - ) - ); - statements.push(dialectConfigImportDecl); - break; - } - - case 'postgresql': { - // `import { toDialectConfig } from '@zenstackhq/runtime/utils/pg-utils';` - const dialectConfigImportDecl = - ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamedImports([ - ts.factory.createImportSpecifier( - false, - undefined, - ts.factory.createIdentifier( - 'toDialectConfig' - ) - ), - ]) - ), - ts.factory.createStringLiteral( - '@zenstackhq/runtime/utils/pg-utils' - ) - ); - statements.push(dialectConfigImportDecl); - break; - } - } - const declaration = ts.factory.createVariableStatement( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], ts.factory.createVariableDeclarationList( @@ -312,10 +231,6 @@ export class TsSchemaGenerator { 'type', ts.factory.createStringLiteral(dsProvider.type) ), - ts.factory.createPropertyAssignment( - 'dialectConfigProvider', - this.createDialectConfigProvider(dsProvider) - ), ], true ); @@ -1017,102 +932,6 @@ export class TsSchemaGenerator { : undefined; } - private createDialectConfigProvider( - dsProvider: - | { type: string; env: undefined; url: string } - | { type: string; env: string; url: undefined } - ) { - const type = dsProvider.type; - - let urlExpr: ts.Expression; - if (dsProvider.env !== undefined) { - urlExpr = ts.factory.createIdentifier( - `process.env['${dsProvider.env}']` - ); - } else { - urlExpr = ts.factory.createStringLiteral(dsProvider.url); - - if (type === 'sqlite') { - // convert file: URL to a regular path - let parsedUrl: URL | undefined; - try { - parsedUrl = new URL(dsProvider.url); - } catch { - // ignore - } - - if (parsedUrl) { - if (parsedUrl.protocol !== 'file:') { - throw new Error( - 'Invalid SQLite URL: only file protocol is supported' - ); - } - urlExpr = ts.factory.createStringLiteral( - dsProvider.url.replace(/^file:/, '') - ); - } - } - } - - return match(type) - .with('sqlite', () => { - return ts.factory.createFunctionExpression( - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ts.factory.createBlock( - [ - ts.factory.createReturnStatement( - ts.factory.createCallExpression( - ts.factory.createIdentifier( - 'toDialectConfig' - ), - undefined, - [ - urlExpr, - ts.factory.createIdentifier( - `typeof __dirname !== 'undefined' ? __dirname : path.dirname(url.fileURLToPath(import.meta.url))` - ), - ] - ) - ), - ], - true - ) - ); - }) - .with('postgresql', () => { - return ts.factory.createFunctionExpression( - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ts.factory.createBlock( - [ - ts.factory.createReturnStatement( - ts.factory.createCallExpression( - ts.factory.createIdentifier( - 'toDialectConfig' - ), - undefined, - [urlExpr] - ) - ), - ], - true - ) - ); - }) - .otherwise(() => { - throw new Error(`Unsupported provider: ${type}`); - }); - } - private createProceduresObject(procedures: Procedure[]) { return ts.factory.createObjectLiteralExpression( procedures.map((proc) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d544c48..d870931e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,9 +195,6 @@ importers: pg: specifier: ^8.13.1 version: 8.13.1 - pg-connection-string: - specifier: ^2.9.0 - version: 2.9.0 tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -2224,9 +2221,6 @@ packages: pg-connection-string@2.7.0: resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} - pg-connection-string@2.9.0: - resolution: {integrity: sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==} - pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -4832,8 +4826,6 @@ snapshots: pg-connection-string@2.7.0: {} - pg-connection-string@2.9.0: {} - pg-int8@1.0.1: {} pg-numeric@1.0.2: {} diff --git a/samples/blog/README.md b/samples/blog/README.md index 35dbb397..8b351a5f 100644 --- a/samples/blog/README.md +++ b/samples/blog/README.md @@ -22,7 +22,10 @@ ```ts import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './zenstack/schema'; - const db = ZenStackClient(schema); + import SQLite from 'better-sqlite3'; + const db = ZenStackClient(schema, { + dialectConfig: { database: new SQLite('./zenstack/dev.db') }, + }); ``` - Run `zenstack migrate dev` to generate and apply database migrations. It internally calls `prisma migrate dev`. Same for `zenstack migrate deploy`. - ZenStack v3 doesn't generate into "node_modules" anymore. The generated TypeScript schema file can be checked in to source control, and you decide how to build or bundle it with your application. @@ -36,7 +39,6 @@ Replicating PrismaClient's CRUD API is around 80% done, including typing and run Not supported yet: -- `$transaction` - `$extends` ### 2. Using Kysely expression builder to express complex queries in `where` diff --git a/samples/blog/main.ts b/samples/blog/main.ts index e88add64..28675e18 100644 --- a/samples/blog/main.ts +++ b/samples/blog/main.ts @@ -1,8 +1,12 @@ import { ZenStackClient } from '@zenstackhq/runtime'; +import SQLite from 'better-sqlite3'; import { schema } from './zenstack/schema'; async function main() { const db = new ZenStackClient(schema, { + dialectConfig: { + database: new SQLite('./zenstack/dev.db'), + }, computedFields: { User: { postCount: (eb) => @@ -14,12 +18,6 @@ async function main() { ), }, }, - procedures: { - signUp: async (client, email, name) => { - console.log('Calling "signUp" proc:', email, name); - return client.user.create({ data: { email, name } }); - }, - }, }).$use({ id: 'cost-logger', async onQuery({ model, operation, proceed, queryArgs }) { @@ -96,13 +94,6 @@ async function main() { }, }); console.log('User found with computed field:', userWithMorePosts); - - // create with custom procedure - const newUser = await db.$procedures.signUp( - 'marvin@zenstack.dev', - 'Marvin' - ); - console.log('User signed up:', newUser); } main(); diff --git a/samples/blog/zenstack/schema.ts b/samples/blog/zenstack/schema.ts index 20c5aa80..a73b7230 100644 --- a/samples/blog/zenstack/schema.ts +++ b/samples/blog/zenstack/schema.ts @@ -4,15 +4,9 @@ ////////////////////////////////////////////////////////////////////////////////////////////// import { type SchemaDef, type OperandExpression, ExpressionUtils } from "@zenstackhq/runtime/schema"; -import path from "node:path"; -import url from "node:url"; -import { toDialectConfig } from "@zenstackhq/runtime/utils/sqlite-utils"; export const schema = { provider: { - type: "sqlite", - dialectConfigProvider: function () { - return toDialectConfig("./dev.db", typeof __dirname !== 'undefined' ? __dirname : path.dirname(url.fileURLToPath(import.meta.url))); - } + type: "sqlite" }, models: { User: { @@ -166,26 +160,6 @@ export const schema = { } }, authType: "User", - procedures: { - signUp: { - params: [ - { name: "email", type: "String" }, - { name: "name", optional: true, type: "String" } - ] as [ - email: { - "name": "email"; - "type": "String"; - }, - name: { - "name": "name"; - "type": "String"; - "optional": true; - } - ], - returnType: "User", - mutation: true - } - }, plugins: {} } as const satisfies SchemaDef; export type SchemaType = typeof schema; diff --git a/samples/blog/zenstack/schema.zmodel b/samples/blog/zenstack/schema.zmodel index 5f1cd815..c9cb02ee 100644 --- a/samples/blog/zenstack/schema.zmodel +++ b/samples/blog/zenstack/schema.zmodel @@ -9,15 +9,15 @@ enum Role { } model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique - name String? - postCount Int @computed - role Role @default(USER) - posts Post[] - profile Profile? + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + name String? + postCount Int @computed + role Role @default(USER) + posts Post[] + profile Profile? } model Profile { @@ -38,5 +38,3 @@ model Post { author User @relation(fields: [authorId], references: [id]) authorId String } - -mutation procedure signUp(email: String, name: String?): User