Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

I want to thank you first for considering contributing to ZenStack 🙏🏻. It's people like you who make ZenStack a better toolkit that benefits more developers!

Before you start working on anything major, please make sure to open an issue or discuss with us in our [Discord](https://discord.gg/Ykhr738dUe) first. This will help ensure your work aligns with the project's goals and avoid duplication of effort.
Before you start working on anything major, please make sure to create a topic in the [feature-work](https://discord.com/channels/1035538056146595961/1458658287015952498) discord channel (preferred) or create a GitHub issue to discuss it first. This will help ensure your work aligns with the project's goals and avoid duplication of effort.

## Prerequisites

Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
- [x] JSDoc for CRUD methods
- [x] Cache validation schemas
- [x] Compound ID
- [ ] Cross field comparison
- [x] Many-to-many relation
- [x] Self relation
- [ ] Empty AND/OR/NOT behavior
Expand All @@ -101,6 +100,7 @@
- [x] Validation
- [ ] Access Policy
- [ ] Short-circuit pre-create check for scalar-field only policies
- [x] Field-level policies
- [x] Inject "on conflict do update"
- [x] `check` function
- [ ] Custom functions
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-v3",
"version": "3.1.1",
"version": "3.2.0",
"description": "ZenStack",
"packageManager": "[email protected]",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/better-auth",
"version": "3.1.1",
"version": "3.2.0",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"type": "module",
"scripts": {
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.1.1",
"version": "3.2.0",
"type": "module",
"author": {
"name": "ZenStack Team"
Expand All @@ -21,6 +21,10 @@
"zen": "bin/cli",
"zenstack": "bin/cli"
},
"files": [
"dist",
"bin"
],
"scripts": {
"build": "tsc --noEmit && tsup-node",
"watch": "tsup-node --watch",
Expand All @@ -35,6 +39,7 @@
"@zenstackhq/common-helpers": "workspace:*",
"@zenstackhq/language": "workspace:*",
"@zenstackhq/sdk": "workspace:*",
"chokidar": "^5.0.0",
"colors": "1.4.0",
"commander": "^8.3.0",
"execa": "^9.6.0",
Expand All @@ -58,5 +63,8 @@
"@zenstackhq/vitest-config": "workspace:*",
"better-sqlite3": "catalog:",
"tmp": "catalog:"
},
"engines": {
"node": ">=20"
}
}
104 changes: 101 additions & 3 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { invariant } from '@zenstackhq/common-helpers';
import { isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast';
import { invariant, singleDebounce } from '@zenstackhq/common-helpers';
import { ZModelLanguageMetaData } from '@zenstackhq/language';
import { type AbstractDeclaration, isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast';
import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils';
import { type CliPlugin } from '@zenstackhq/sdk';
import colors from 'colors';
import { createJiti } from 'jiti';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { watch } from 'chokidar';
import ora, { type Ora } from 'ora';
import { CliError } from '../cli-error';
import * as corePlugins from '../plugins';
Expand All @@ -16,6 +18,7 @@ type Options = {
schema?: string;
output?: string;
silent: boolean;
watch: boolean;
lite: boolean;
liteOnly: boolean;
};
Expand All @@ -24,6 +27,96 @@ type Options = {
* CLI action for generating code from schema
*/
export async function run(options: Options) {
const model = await pureGenerate(options, false);

if (options.watch) {
const logsEnabled = !options.silent;

if (logsEnabled) {
console.log(colors.green(`\nEnabled watch mode!`));
}

const schemaExtensions = ZModelLanguageMetaData.fileExtensions;

// Get real models file path (cuz its merged into single document -> we need use cst nodes)
const getRootModelWatchPaths = (model: Model) => new Set<string>(
(
model.declarations.filter(
(v) =>
v.$cstNode?.parent?.element.$type === 'Model' &&
!!v.$cstNode.parent.element.$document?.uri?.fsPath,
) as AbstractDeclaration[]
).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath),
);

const watchedPaths = getRootModelWatchPaths(model);

if (logsEnabled) {
const logPaths = [...watchedPaths].map((at) => `- ${at}`).join('\n');
console.log(`Watched file paths:\n${logPaths}`);
}

const watcher = watch([...watchedPaths], {
alwaysStat: false,
ignoreInitial: true,
ignorePermissionErrors: true,
ignored: (at) => !schemaExtensions.some((ext) => at.endsWith(ext)),
});

// prevent save multiple files and run multiple times
const reGenerateSchema = singleDebounce(async () => {
if (logsEnabled) {
console.log('Got changes, run generation!');
}

try {
const newModel = await pureGenerate(options, true);
const allModelsPaths = getRootModelWatchPaths(newModel);
const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at));
const removeModelPaths = [...watchedPaths].filter((at) => !allModelsPaths.has(at));

if (newModelPaths.length) {
if (logsEnabled) {
const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n');
console.log(`Added file(s) to watch:\n${logPaths}`);
}

newModelPaths.forEach((at) => watchedPaths.add(at));
watcher.add(newModelPaths);
}

if (removeModelPaths.length) {
if (logsEnabled) {
const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n');
console.log(`Removed file(s) from watch:\n${logPaths}`);
}

removeModelPaths.forEach((at) => watchedPaths.delete(at));
watcher.unwatch(removeModelPaths);
}
} catch (e) {
console.error(e);
}
}, 500, true);

watcher.on('unlink', (pathAt) => {
if (logsEnabled) {
console.log(`Removed file from watch: ${pathAt}`);
}

watchedPaths.delete(pathAt);
watcher.unwatch(pathAt);

reGenerateSchema();
});

watcher.on('change', () => {
reGenerateSchema();
});
}
}

async function pureGenerate(options: Options, fromWatch: boolean) {
const start = Date.now();

const schemaFile = getSchemaFile(options.schema);
Expand All @@ -35,7 +128,9 @@ export async function run(options: Options) {

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.

if (!fromWatch) {
console.log(`You can now create a ZenStack client with it.

\`\`\`ts
import { ZenStackClient } from '@zenstackhq/orm';
Expand All @@ -47,7 +142,10 @@ const client = new ZenStackClient(schema, {
\`\`\`

Check documentation: https://zenstack.dev/docs/`);
}
}

return model;
}

function getOutputPath(options: Options, schemaFile: string) {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function createProgram() {
.addOption(schemaOption)
.addOption(noVersionCheckOption)
.addOption(new Option('-o, --output <path>', 'default output directory for code generation'))
.addOption(new Option('-w, --watch', 'enable watch mode').default(false))
.addOption(new Option('--lite', 'also generate a lite version of schema without attributes').default(false))
.addOption(new Option('--lite-only', 'only generate lite version of schema without attributes').default(false))
.addOption(new Option('--silent', 'suppress all output except errors').default(false))
Expand Down Expand Up @@ -220,6 +221,11 @@ async function main() {
}
}

if (program.args.includes('generate') && (program.args.includes('-w') || program.args.includes('--watch'))) {
// A "hack" way to prevent the process from terminating because we don't want to stop it.
return;
}

if (telemetry.isTracking) {
// give telemetry a chance to send events before exit
setTimeout(() => {
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/test/ts-schema-gen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,30 @@ model Post {
});
});

it('generates correct procedures with array params and returns', async () => {
const { schema } = await generateTsSchema(`
model User {
id Int @id
}

procedure findByIds(ids: Int[]): User[]
procedure getIds(): Int[]
`);

expect(schema.procedures).toMatchObject({
findByIds: {
params: { ids: { name: 'ids', type: 'Int', array: true } },
returnType: 'User',
returnArray: true,
},
getIds: {
params: {},
returnType: 'Int',
returnArray: true,
},
});
});

it('merges fields and attributes from mixins', async () => {
const { schema } = await generateTsSchema(`
type Timestamped {
Expand Down
5 changes: 4 additions & 1 deletion packages/clients/client-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/client-helpers",
"version": "3.1.1",
"version": "3.2.0",
"description": "Helpers for implementing clients that consume ZenStack's CRUD service",
"type": "module",
"scripts": {
Expand All @@ -13,6 +13,9 @@
},
"author": "ZenStack Team",
"license": "MIT",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/clients/tanstack-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/tanstack-query",
"version": "3.1.1",
"version": "3.2.0",
"description": "TanStack Query Client for consuming ZenStack v3's CRUD service",
"type": "module",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions packages/clients/tanstack-query/src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const CUSTOM_PROC_ROUTE_NAME = '$procs';
6 changes: 5 additions & 1 deletion packages/clients/tanstack-query/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Logger, OptimisticDataProvider } from '@zenstackhq/client-helpers';
import type { FetchFn } from '@zenstackhq/client-helpers/fetch';
import type { OperationsIneligibleForDelegateModels } from '@zenstackhq/orm';
import type { GetProcedureNames, OperationsIneligibleForDelegateModels, ProcedureFunc } from '@zenstackhq/orm';
import type { GetModels, IsDelegateModel, SchemaDef } from '@zenstackhq/schema';

/**
Expand Down Expand Up @@ -76,3 +76,7 @@ type WithOptimisticFlag<T> = T extends object
: T;

export type WithOptimistic<T> = T extends Array<infer U> ? Array<WithOptimisticFlag<U>> : WithOptimisticFlag<T>;

export type ProcedureReturn<Schema extends SchemaDef, Name extends GetProcedureNames<Schema>> = Awaited<
ReturnType<ProcedureFunc<Schema, Name>>
>;
Loading