From 9b95c290f8049433c7e28b77f7d819a08a452d73 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Tue, 30 Dec 2025 22:26:17 +0300 Subject: [PATCH 01/14] feat(cli): implement watch mode for generate --- packages/cli/package.json | 1 + packages/cli/src/actions/generate.ts | 98 +++++++++++++++++++++++++++- packages/cli/src/index.ts | 6 ++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 39540625..7c81f425 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,6 +35,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", diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index c41a99ea..b5e99df2 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -1,5 +1,6 @@ import { invariant } from '@zenstackhq/common-helpers'; -import { isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast'; +import { ZModelLanguageMetaData } from '@zenstackhq/language'; +import { isPlugin, isDataModel, type DataModel, 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'; @@ -16,6 +17,7 @@ type Options = { schema?: string; output?: string; silent: boolean; + watch: boolean; lite: boolean; liteOnly: boolean; }; @@ -24,6 +26,93 @@ 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(`\nEnable watch mode!`)); + } + + const schemaExtensions = ZModelLanguageMetaData.fileExtensions; + + // Get real models file path (cuz its merged into single document -> we need use cst nodes) + const getModelAllPaths = (model: Model) => new Set( + ( + model.declarations.filter( + (v) => + isDataModel(v) && + v.$cstNode?.parent?.element.$type === 'Model' && + !!v.$cstNode.parent.element.$document?.uri?.fsPath, + ) as DataModel[] + ).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath), + ); + + const { watch } = await import('chokidar'); + + const watchedPaths = getModelAllPaths(model); + let reGenerateSchemaTimeout: ReturnType | undefined; + + 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)), + }); + + const reGenerateSchema = () => { + clearTimeout(reGenerateSchemaTimeout); + + // prevent save multiple files and run multiple times + reGenerateSchemaTimeout = setTimeout(async () => { + if (logsEnabled) { + console.log('Got changes, run generation!'); + } + + try { + const newModel = await pureGenerate(options, true); + const allModelsPaths = getModelAllPaths(newModel); + const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at)); + + if (newModelPaths.length) { + if (logsEnabled) { + const logPaths = [...newModelPaths].map((at) => `- ${at}`).join('\n'); + console.log(`Add file(s) to watch:\n${logPaths}`); + } + + newModelPaths.forEach((at) => watchedPaths.add(at)); + watcher.add(newModelPaths); + } + } catch (e) { + console.error(e); + } + }, 500); + }; + + watcher.on('unlink', (pathAt) => { + if (logsEnabled) { + console.log(`Remove 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); @@ -35,7 +124,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'; @@ -47,7 +138,10 @@ const client = new ZenStackClient(schema, { \`\`\` Check documentation: https://zenstack.dev/docs/`); + } } + + return model; } function getOutputPath(options: Options, schemaFile: string) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c2307fa1..0d663044 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -68,6 +68,7 @@ function createProgram() { .addOption(schemaOption) .addOption(noVersionCheckOption) .addOption(new Option('-o, --output ', '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)) @@ -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(() => { From 48959490392ec3ae94dedb3d8288f5f639c05813 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Mon, 5 Jan 2026 22:54:58 +0300 Subject: [PATCH 02/14] chore(root): update pnpm-lock.yaml --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba5f04d5..df889c6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: '@zenstackhq/sdk': specifier: workspace:* version: link:../sdk + chokidar: + specifier: ^5.0.0 + version: 5.0.0 colors: specifier: 1.4.0 version: 1.4.0 From 469cb266282b047bb18dce6791d5c3017be1d8b4 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Mon, 5 Jan 2026 22:56:14 +0300 Subject: [PATCH 03/14] chore(cli): track all model declaration and removed paths, logs in past tense --- packages/cli/src/actions/generate.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index b5e99df2..c48c4aa5 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -1,6 +1,6 @@ import { invariant } from '@zenstackhq/common-helpers'; import { ZModelLanguageMetaData } from '@zenstackhq/language'; -import { isPlugin, isDataModel, type DataModel, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast'; +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'; @@ -32,26 +32,25 @@ export async function run(options: Options) { const logsEnabled = !options.silent; if (logsEnabled) { - console.log(colors.green(`\nEnable watch mode!`)); + 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 getModelAllPaths = (model: Model) => new Set( + const getRootModelWatchPaths = (model: Model) => new Set( ( model.declarations.filter( (v) => - isDataModel(v) && v.$cstNode?.parent?.element.$type === 'Model' && !!v.$cstNode.parent.element.$document?.uri?.fsPath, - ) as DataModel[] + ) as AbstractDeclaration[] ).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath), ); const { watch } = await import('chokidar'); - const watchedPaths = getModelAllPaths(model); + const watchedPaths = getRootModelWatchPaths(model); let reGenerateSchemaTimeout: ReturnType | undefined; if (logsEnabled) { @@ -77,18 +76,29 @@ export async function run(options: Options) { try { const newModel = await pureGenerate(options, true); - const allModelsPaths = getModelAllPaths(newModel); + 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(`Add file(s) to watch:\n${logPaths}`); + 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(`Added file(s) to watch:\n${logPaths}`); + } + + removeModelPaths.forEach((at) => watchedPaths.add(at)); + watcher.add(removeModelPaths); + } } catch (e) { console.error(e); } @@ -97,7 +107,7 @@ export async function run(options: Options) { watcher.on('unlink', (pathAt) => { if (logsEnabled) { - console.log(`Remove file from watch: ${pathAt}`); + console.log(`Removed file from watch: ${pathAt}`); } watchedPaths.delete(pathAt); From 92814cad4d3c7b422afb68f54ba7e153cc97370a Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Mon, 5 Jan 2026 23:33:59 +0300 Subject: [PATCH 04/14] fix(cli): typo, unused double array from --- packages/cli/src/actions/generate.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index c48c4aa5..aefb9083 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -82,7 +82,7 @@ export async function run(options: Options) { if (newModelPaths.length) { if (logsEnabled) { - const logPaths = [...newModelPaths].map((at) => `- ${at}`).join('\n'); + const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n'); console.log(`Added file(s) to watch:\n${logPaths}`); } @@ -92,12 +92,12 @@ export async function run(options: Options) { if (removeModelPaths.length) { if (logsEnabled) { - const logPaths = [...removeModelPaths].map((at) => `- ${at}`).join('\n'); - console.log(`Added file(s) to watch:\n${logPaths}`); + const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n'); + console.log(`Removed file(s) from watch:\n${logPaths}`); } - removeModelPaths.forEach((at) => watchedPaths.add(at)); - watcher.add(removeModelPaths); + removeModelPaths.forEach((at) => watchedPaths.delete(at)); + watcher.unwatch(removeModelPaths); } } catch (e) { console.error(e); From c9f31ea2fbd1d36d94b9dc0bd5472288a49c5d3d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:00:31 +0800 Subject: [PATCH 05/14] fix(orm): preserve zod validation errors when validating custom json types --- .../orm/src/client/crud/validator/index.ts | 8 ++++++-- .../orm/client-api/typed-json-fields.test.ts | 2 +- tests/regression/test/issue-558.test.ts | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/regression/test/issue-558.test.ts diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index dc1a6f83..072e34b6 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -382,9 +382,13 @@ export class InputValidator { // zod doesn't preserve object field order after parsing, here we use a // validation-only custom schema and use the original data if parsing // is successful - const finalSchema = z.custom((v) => { - return schema.safeParse(v).success; + const finalSchema = z.any().superRefine((value, ctx) => { + const parseResult = schema.safeParse(value); + if (!parseResult.success) { + parseResult.error.issues.forEach((issue) => ctx.addIssue(issue as any)); + } }); + this.setSchemaCache(key!, finalSchema); return finalSchema; } diff --git a/tests/e2e/orm/client-api/typed-json-fields.test.ts b/tests/e2e/orm/client-api/typed-json-fields.test.ts index a213c1d8..f5a8945c 100644 --- a/tests/e2e/orm/client-api/typed-json-fields.test.ts +++ b/tests/e2e/orm/client-api/typed-json-fields.test.ts @@ -121,7 +121,7 @@ model User { }, }, }), - ).rejects.toThrow(/invalid/i); + ).rejects.toThrow('data.identity.providers[0].id'); }); it('works with find', async () => { diff --git a/tests/regression/test/issue-558.test.ts b/tests/regression/test/issue-558.test.ts new file mode 100644 index 00000000..4a76e31f --- /dev/null +++ b/tests/regression/test/issue-558.test.ts @@ -0,0 +1,19 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #558', () => { + it('verifies issue 558', async () => { + const db = await createTestClient(` +type Foo { + x Int +} + +model Model { + id String @id @default(cuid()) + foo Foo @json +} + `); + + await expect(db.model.create({ data: { foo: { x: 'hello' } } })).rejects.toThrow('data.foo.x'); + }); +}); From b625c508d1db0a60ee887c3487b91c2b0e28b149 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:04:27 +0800 Subject: [PATCH 06/14] update --- packages/orm/src/client/crud/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 072e34b6..8b16a9de 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -499,7 +499,7 @@ export class InputValidator { } // expression builder - fields['$expr'] = z.custom((v) => typeof v === 'function').optional(); + fields['$expr'] = z.custom((v) => typeof v === 'function', { error: '"$expr" must be a function' }).optional(); // logical operators fields['AND'] = this.orArray( From 835a01ba55562f37c5ff6f07aba08e898e7f2f17 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Wed, 7 Jan 2026 04:28:36 +0300 Subject: [PATCH 07/14] chore(cli): move import, fix parallel generation on watch --- packages/cli/src/actions/generate.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index aefb9083..ba8fd158 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -8,6 +8,7 @@ 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'; @@ -48,10 +49,9 @@ export async function run(options: Options) { ).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath), ); - const { watch } = await import('chokidar'); - const watchedPaths = getRootModelWatchPaths(model); let reGenerateSchemaTimeout: ReturnType | undefined; + let generationInProgress = false; if (logsEnabled) { const logPaths = [...watchedPaths].map((at) => `- ${at}`).join('\n'); @@ -70,6 +70,12 @@ export async function run(options: Options) { // prevent save multiple files and run multiple times reGenerateSchemaTimeout = setTimeout(async () => { + if (generationInProgress) { + return; + } + + generationInProgress = true; + if (logsEnabled) { console.log('Got changes, run generation!'); } @@ -102,6 +108,8 @@ export async function run(options: Options) { } catch (e) { console.error(e); } + + generationInProgress = false; }, 500); }; From f969ec4ef04525a5ec6e3efacb0381e536d89146 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Wed, 7 Jan 2026 04:40:38 +0300 Subject: [PATCH 08/14] feat(common-helpers): implement single-debounce --- packages/common-helpers/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/common-helpers/src/index.ts b/packages/common-helpers/src/index.ts index 07c4fff5..146609fb 100644 --- a/packages/common-helpers/src/index.ts +++ b/packages/common-helpers/src/index.ts @@ -4,6 +4,7 @@ export * from './is-plain-object'; export * from './lower-case-first'; export * from './param-case'; export * from './safe-json-stringify'; +export * from './single-debounce'; export * from './sleep'; export * from './tiny-invariant'; export * from './upper-case-first'; From 87a95f0c58538df95eef5598cadc4de7fd0810db Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Wed, 7 Jan 2026 04:40:51 +0300 Subject: [PATCH 09/14] chore(cli): use single-debounce for debouncing --- packages/cli/src/actions/generate.ts | 70 +++++++++++----------------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index ba8fd158..16e3826c 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -1,4 +1,4 @@ -import { invariant } from '@zenstackhq/common-helpers'; +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'; @@ -50,8 +50,6 @@ export async function run(options: Options) { ); const watchedPaths = getRootModelWatchPaths(model); - let reGenerateSchemaTimeout: ReturnType | undefined; - let generationInProgress = false; if (logsEnabled) { const logPaths = [...watchedPaths].map((at) => `- ${at}`).join('\n'); @@ -65,53 +63,41 @@ export async function run(options: Options) { ignored: (at) => !schemaExtensions.some((ext) => at.endsWith(ext)), }); - const reGenerateSchema = () => { - clearTimeout(reGenerateSchemaTimeout); + // prevent save multiple files and run multiple times + const reGenerateSchema = singleDebounce(async () => { + if (logsEnabled) { + console.log('Got changes, run generation!'); + } - // prevent save multiple files and run multiple times - reGenerateSchemaTimeout = setTimeout(async () => { - if (generationInProgress) { - return; - } + 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)); - generationInProgress = true; + if (newModelPaths.length) { + if (logsEnabled) { + const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n'); + console.log(`Added file(s) to watch:\n${logPaths}`); + } - if (logsEnabled) { - console.log('Got changes, run generation!'); + newModelPaths.forEach((at) => watchedPaths.add(at)); + watcher.add(newModelPaths); } - 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}`); } - 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); + removeModelPaths.forEach((at) => watchedPaths.delete(at)); + watcher.unwatch(removeModelPaths); } - - generationInProgress = false; - }, 500); - }; + } catch (e) { + console.error(e); + } + }, 500, true); watcher.on('unlink', (pathAt) => { if (logsEnabled) { From d966e2ab2a751a5be2ed7654de23ff2852b975e2 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Wed, 7 Jan 2026 04:43:16 +0300 Subject: [PATCH 10/14] feat(common-helpers): implement single-debounce --- .../common-helpers/src/single-debounce.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/common-helpers/src/single-debounce.ts diff --git a/packages/common-helpers/src/single-debounce.ts b/packages/common-helpers/src/single-debounce.ts new file mode 100644 index 00000000..86b0e379 --- /dev/null +++ b/packages/common-helpers/src/single-debounce.ts @@ -0,0 +1,31 @@ +export function singleDebounce(cb: () => void | PromiseLike, debounceMc: number, reRunOnInProgressCall: boolean = false) { + let timeout: ReturnType | undefined; + let inProgress = false; + let pendingInProgress = false; + + const run = async () => { + if (inProgress && reRunOnInProgressCall) { + pendingInProgress = true; + return; + } + + inProgress = true; + pendingInProgress = false; + + try { + await cb(); + } finally { + inProgress = false; + + if (pendingInProgress) { + await run(); + } + } + }; + + return () => { + clearTimeout(timeout); + + timeout = setTimeout(run, debounceMc); + } +} From 385e791d92fb5e0834515380095aab765a747ddc Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Wed, 7 Jan 2026 14:44:49 +0300 Subject: [PATCH 11/14] fix(common-helpers): re run single-debounce --- packages/common-helpers/src/single-debounce.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/common-helpers/src/single-debounce.ts b/packages/common-helpers/src/single-debounce.ts index 86b0e379..f16091e5 100644 --- a/packages/common-helpers/src/single-debounce.ts +++ b/packages/common-helpers/src/single-debounce.ts @@ -4,8 +4,11 @@ export function singleDebounce(cb: () => void | PromiseLike, debounceMc: n let pendingInProgress = false; const run = async () => { - if (inProgress && reRunOnInProgressCall) { - pendingInProgress = true; + if (inProgress) { + if (reRunOnInProgressCall) { + pendingInProgress = true; + } + return; } From 326b84623f48e15da92610d264a611cf0fb522b9 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:45:53 +0800 Subject: [PATCH 12/14] fix(tanstack): avoid invalidating queries for custom proc mutations --- .../tanstack-query/src/common/constants.ts | 1 + packages/clients/tanstack-query/src/react.ts | 125 +++++---- .../tanstack-query/src/svelte/index.svelte.ts | 137 +++++---- packages/clients/tanstack-query/src/vue.ts | 124 +++++---- .../orm/src/client/crud/operations/base.ts | 30 +- packages/server/src/api/common/procedures.ts | 2 +- .../next.js/app/api/model/[...path]/route.ts | 2 +- samples/next.js/app/feeds/page.tsx | 59 ++++ samples/next.js/app/layout.tsx | 17 +- samples/next.js/app/page.tsx | 190 +++++++------ samples/next.js/app/signup/page.tsx | 85 ++++++ samples/next.js/lib/db.ts | 17 +- samples/next.js/zenstack/input.ts | 4 +- samples/next.js/zenstack/schema-lite.ts | 14 + samples/next.js/zenstack/schema.ts | 14 + samples/next.js/zenstack/schema.zmodel | 26 +- samples/next.js/zenstack/seed.ts | 12 +- samples/nuxt/app/app.vue | 148 +--------- samples/nuxt/app/layouts/default.vue | 10 + samples/nuxt/app/pages/feeds.vue | 52 ++++ samples/nuxt/app/pages/index.vue | 155 +++++++++++ samples/nuxt/app/pages/signup.vue | 80 ++++++ samples/nuxt/app/plugins/tanstack-query.ts | 4 +- samples/nuxt/server/api/model/[...].ts | 2 +- samples/nuxt/server/utils/db.ts | 19 +- samples/nuxt/zenstack/input.ts | 4 +- samples/nuxt/zenstack/schema-lite.ts | 14 + samples/nuxt/zenstack/schema.ts | 14 + samples/nuxt/zenstack/schema.zmodel | 26 +- samples/nuxt/zenstack/seed.ts | 12 +- samples/shared/schema.zmodel | 28 ++ samples/sveltekit/src/app.d.ts | 14 +- samples/sveltekit/src/lib/db.ts | 15 + samples/sveltekit/src/routes/+layout.svelte | 39 ++- samples/sveltekit/src/routes/+page.svelte | 207 +++++++------- .../sveltekit/src/routes/feeds/+page.svelte | 57 ++++ .../sveltekit/src/routes/signup/+page.svelte | 86 ++++++ samples/sveltekit/src/zenstack/input.ts | 61 +++- samples/sveltekit/src/zenstack/schema-lite.ts | 206 +++++++------- samples/sveltekit/src/zenstack/schema.ts | 261 +++++++++++------- 40 files changed, 1547 insertions(+), 826 deletions(-) create mode 100644 packages/clients/tanstack-query/src/common/constants.ts create mode 100644 samples/next.js/app/feeds/page.tsx create mode 100644 samples/next.js/app/signup/page.tsx mode change 100644 => 120000 samples/next.js/zenstack/schema.zmodel create mode 100644 samples/nuxt/app/layouts/default.vue create mode 100644 samples/nuxt/app/pages/feeds.vue create mode 100644 samples/nuxt/app/pages/index.vue create mode 100644 samples/nuxt/app/pages/signup.vue mode change 100644 => 120000 samples/nuxt/zenstack/schema.zmodel create mode 100644 samples/shared/schema.zmodel create mode 100644 samples/sveltekit/src/routes/feeds/+page.svelte create mode 100644 samples/sveltekit/src/routes/signup/+page.svelte diff --git a/packages/clients/tanstack-query/src/common/constants.ts b/packages/clients/tanstack-query/src/common/constants.ts new file mode 100644 index 00000000..15684479 --- /dev/null +++ b/packages/clients/tanstack-query/src/common/constants.ts @@ -0,0 +1 @@ +export const CUSTOM_PROC_ROUTE_NAME = '$procs'; diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index 8b3d9329..75045f5c 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -55,6 +55,7 @@ import type { import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { createContext, useContext } from 'react'; import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client'; +import { CUSTOM_PROC_ROUTE_NAME } from './common/constants'; import { getQueryKey } from './common/query-key'; import type { ExtraMutationOptions, @@ -350,30 +351,36 @@ export function useClientQueries | undefined; if (procedures) { - const buildProcedureHooks = (endpointModel: '$procs') => { + const buildProcedureHooks = () => { return Object.keys(procedures).reduce((acc, name) => { const procDef = procedures[name]; if (procDef?.mutation) { acc[name] = { useMutation: (hookOptions?: any) => - useInternalMutation(schema, endpointModel, 'POST', name, { ...options, ...hookOptions }), + useInternalMutation(schema, CUSTOM_PROC_ROUTE_NAME, 'POST', name, { + ...options, + ...hookOptions, + }), }; } else { acc[name] = { useQuery: (args?: any, hookOptions?: any) => - useInternalQuery(schema, endpointModel, name, args, { ...options, ...hookOptions }), + useInternalQuery(schema, CUSTOM_PROC_ROUTE_NAME, name, args, { + ...options, + ...hookOptions, + }), useSuspenseQuery: (args?: any, hookOptions?: any) => - useInternalSuspenseQuery(schema, endpointModel, name, args, { + useInternalSuspenseQuery(schema, CUSTOM_PROC_ROUTE_NAME, name, args, { ...options, ...hookOptions, }), useInfiniteQuery: (args?: any, hookOptions?: any) => - useInternalInfiniteQuery(schema, endpointModel, name, args, { + useInternalInfiniteQuery(schema, CUSTOM_PROC_ROUTE_NAME, name, args, { ...options, ...hookOptions, }), useSuspenseInfiniteQuery: (args?: any, hookOptions?: any) => - useInternalSuspenseInfiniteQuery(schema, endpointModel, name, args, { + useInternalSuspenseInfiniteQuery(schema, CUSTOM_PROC_ROUTE_NAME, name, args, { ...options, ...hookOptions, }), @@ -383,7 +390,7 @@ export function useClientQueries( }; const finalOptions = { ...options, mutationFn }; - const invalidateQueries = options?.invalidateQueries !== false; - const optimisticUpdate = !!options?.optimisticUpdate; - - if (!optimisticUpdate) { - // if optimistic update is not enabled, invalidate related queries on success - if (invalidateQueries) { - const invalidator = createInvalidator( + if (model !== CUSTOM_PROC_ROUTE_NAME) { + // not a custom procedure, set up optimistic update and invalidation + + const invalidateQueries = options?.invalidateQueries !== false; + const optimisticUpdate = !!options?.optimisticUpdate; + + if (!optimisticUpdate) { + // if optimistic update is not enabled, invalidate related queries on success + if (invalidateQueries) { + const invalidator = createInvalidator( + model, + operation, + schema, + (predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + ); + const origOnSuccess = finalOptions.onSuccess; + finalOptions.onSuccess = async (...args) => { + // execute invalidator prior to user-provided onSuccess + await invalidator(...args); + + // call user-provided onSuccess + await origOnSuccess?.(...args); + }; + } + } else { + // schedule optimistic update on mutate + const optimisticUpdater = createOptimisticUpdater( model, operation, schema, - (predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + { optimisticDataProvider: finalOptions.optimisticDataProvider }, + () => getAllQueries(queryClient), logging, ); - const origOnSuccess = finalOptions.onSuccess; - finalOptions.onSuccess = async (...args) => { - // execute invalidator prior to user-provided onSuccess - await invalidator(...args); + const origOnMutate = finalOptions.onMutate; + finalOptions.onMutate = async (...args) => { + // execute optimistic update + await optimisticUpdater(...args); - // call user-provided onSuccess - await origOnSuccess?.(...args); + // call user-provided onMutate + return origOnMutate?.(...args); }; - } - } else { - // schedule optimistic update on mutate - const optimisticUpdater = createOptimisticUpdater( - model, - operation, - schema, - { optimisticDataProvider: finalOptions.optimisticDataProvider }, - () => getAllQueries(queryClient), - logging, - ); - const origOnMutate = finalOptions.onMutate; - finalOptions.onMutate = async (...args) => { - // execute optimistic update - await optimisticUpdater(...args); - - // call user-provided onMutate - return origOnMutate?.(...args); - }; - - if (invalidateQueries) { - // invalidate related queries on settled (success or error) - const invalidator = createInvalidator( - model, - operation, - schema, - (predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), - logging, - ); - const origOnSettled = finalOptions.onSettled; - finalOptions.onSettled = async (...args) => { - // execute invalidator prior to user-provided onSettled - await invalidator(...args); - // call user-provided onSettled - return origOnSettled?.(...args); - }; + if (invalidateQueries) { + // invalidate related queries on settled (success or error) + const invalidator = createInvalidator( + model, + operation, + schema, + (predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + ); + const origOnSettled = finalOptions.onSettled; + finalOptions.onSettled = async (...args) => { + // execute invalidator prior to user-provided onSettled + await invalidator(...args); + + // call user-provided onSettled + return origOnSettled?.(...args); + }; + } } } diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index 0bd1bd38..0c976b71 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -56,6 +56,7 @@ import type { import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { getContext, setContext } from 'svelte'; import { getAllQueries, invalidateQueriesMatchingPredicate } from '../common/client'; +import { CUSTOM_PROC_ROUTE_NAME } from '../common/constants'; import { getQueryKey } from '../common/query-key'; import type { ExtraMutationOptions, @@ -297,27 +298,39 @@ export function useClientQueries | undefined; if (procedures) { - const buildProcedureHooks = (endpointModel: '$procs') => { + const buildProcedureHooks = () => { return Object.keys(procedures).reduce((acc, name) => { const procDef = procedures[name]; if (procDef?.mutation) { acc[name] = { useMutation: (hookOptions?: any) => - useInternalMutation(schema, endpointModel, 'POST', name, merge(options, hookOptions)), + useInternalMutation( + schema, + CUSTOM_PROC_ROUTE_NAME, + 'POST', + name, + merge(options, hookOptions), + ), }; } else { acc[name] = { useQuery: (args?: any, hookOptions?: any) => - useInternalQuery(schema, endpointModel, name, args, merge(options, hookOptions)), + useInternalQuery(schema, CUSTOM_PROC_ROUTE_NAME, name, args, merge(options, hookOptions)), useInfiniteQuery: (args?: any, hookOptions?: any) => - useInternalInfiniteQuery(schema, endpointModel, name, args, merge(options, hookOptions)), + useInternalInfiniteQuery( + schema, + CUSTOM_PROC_ROUTE_NAME, + name, + args, + merge(options, hookOptions), + ), }; } return acc; }, {} as any); }; - (result as any).$procs = buildProcedureHooks('$procs'); + (result as any).$procs = buildProcedureHooks(); } return result; @@ -533,70 +546,74 @@ export function useInternalMutation( mutationFn, }; - if (!optimisticUpdate) { - // if optimistic update is not enabled, invalidate related queries on success - if (invalidateQueries) { - const invalidator = createInvalidator( + if (model !== CUSTOM_PROC_ROUTE_NAME) { + // not a custom procedure, set up optimistic update and invalidation + + if (!optimisticUpdate) { + // if optimistic update is not enabled, invalidate related queries on success + if (invalidateQueries) { + const invalidator = createInvalidator( + model, + operation, + schema, + (predicate: InvalidationPredicate) => + // @ts-ignore + invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + ); + + // execute invalidator prior to user-provided onSuccess + const origOnSuccess = optionsValue?.onSuccess; + const wrappedOnSuccess: typeof origOnSuccess = async (...args) => { + await invalidator(...args); + await origOnSuccess?.(...args); + }; + result.onSuccess = wrappedOnSuccess; + } + } else { + const optimisticUpdater = createOptimisticUpdater( model, operation, schema, - (predicate: InvalidationPredicate) => - // @ts-ignore - invalidateQueriesMatchingPredicate(queryClient, predicate), + { optimisticDataProvider: optionsValue?.optimisticDataProvider }, + // @ts-ignore + () => getAllQueries(queryClient), logging, ); - // execute invalidator prior to user-provided onSuccess - const origOnSuccess = optionsValue?.onSuccess; - const wrappedOnSuccess: typeof origOnSuccess = async (...args) => { - await invalidator(...args); - await origOnSuccess?.(...args); - }; - result.onSuccess = wrappedOnSuccess; - } - } else { - const optimisticUpdater = createOptimisticUpdater( - model, - operation, - schema, - { optimisticDataProvider: optionsValue?.optimisticDataProvider }, - // @ts-ignore - () => getAllQueries(queryClient), - logging, - ); - - const origOnMutate = optionsValue.onMutate; - const wrappedOnMutate: typeof origOnMutate = async (...args) => { - // execute optimistic updater prior to user-provided onMutate - await optimisticUpdater(...args); - - // call user-provided onMutate - return origOnMutate?.(...args); - }; - - result.onMutate = wrappedOnMutate; + const origOnMutate = optionsValue.onMutate; + const wrappedOnMutate: typeof origOnMutate = async (...args) => { + // execute optimistic updater prior to user-provided onMutate + await optimisticUpdater(...args); - if (invalidateQueries) { - const invalidator = createInvalidator( - model, - operation, - schema, - (predicate: InvalidationPredicate) => - // @ts-ignore - invalidateQueriesMatchingPredicate(queryClient, predicate), - logging, - ); - const origOnSettled = optionsValue.onSettled; - const wrappedOnSettled: typeof origOnSettled = async (...args) => { - // execute invalidator prior to user-provided onSettled - await invalidator(...args); - - // call user-provided onSettled - await origOnSettled?.(...args); + // call user-provided onMutate + return origOnMutate?.(...args); }; - // replace onSettled in mergedOpt - result.onSettled = wrappedOnSettled; + result.onMutate = wrappedOnMutate; + + if (invalidateQueries) { + const invalidator = createInvalidator( + model, + operation, + schema, + (predicate: InvalidationPredicate) => + // @ts-ignore + invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + ); + const origOnSettled = optionsValue.onSettled; + const wrappedOnSettled: typeof origOnSettled = async (...args) => { + // execute invalidator prior to user-provided onSettled + await invalidator(...args); + + // call user-provided onSettled + await origOnSettled?.(...args); + }; + + // replace onSettled in mergedOpt + result.onSettled = wrappedOnSettled; + } } } diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index 7361c2ad..bc0bf39f 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -54,6 +54,7 @@ import type { import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { computed, inject, provide, toValue, unref, type MaybeRefOrGetter, type Ref, type UnwrapRef } from 'vue'; import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client'; +import { CUSTOM_PROC_ROUTE_NAME } from './common/constants'; import { getQueryKey } from './common/query-key'; import type { ExtraMutationOptions, @@ -309,27 +310,39 @@ export function useClientQueries | undefined; if (procedures) { - const buildProcedureHooks = (endpointModel: '$procs') => { + const buildProcedureHooks = () => { return Object.keys(procedures).reduce((acc, name) => { const procDef = procedures[name]; if (procDef?.mutation) { acc[name] = { useMutation: (hookOptions?: any) => - useInternalMutation(schema, endpointModel, 'POST', name, merge(options, hookOptions)), + useInternalMutation( + schema, + CUSTOM_PROC_ROUTE_NAME, + 'POST', + name, + merge(options, hookOptions), + ), }; } else { acc[name] = { useQuery: (args?: any, hookOptions?: any) => - useInternalQuery(schema, endpointModel, name, args, merge(options, hookOptions)), + useInternalQuery(schema, CUSTOM_PROC_ROUTE_NAME, name, args, merge(options, hookOptions)), useInfiniteQuery: (args?: any, hookOptions?: any) => - useInternalInfiniteQuery(schema, endpointModel, name, args, merge(options, hookOptions)), + useInternalInfiniteQuery( + schema, + CUSTOM_PROC_ROUTE_NAME, + name, + args, + merge(options, hookOptions), + ), }; } return acc; }, {} as any); }; - (result as any).$procs = buildProcedureHooks('$procs'); + (result as any).$procs = buildProcedureHooks(); } return result; @@ -552,63 +565,70 @@ export function useInternalMutation( mutationFn, } as UnwrapRef> & ExtraMutationOptions; - const invalidateQueries = optionsValue?.invalidateQueries !== false; - const optimisticUpdate = !!optionsValue?.optimisticUpdate; - - if (!optimisticUpdate) { - if (invalidateQueries) { - const invalidator = createInvalidator( + if (model !== CUSTOM_PROC_ROUTE_NAME) { + // not a custom procedure, set up optimistic update and invalidation + + const invalidateQueries = optionsValue?.invalidateQueries !== false; + const optimisticUpdate = !!optionsValue?.optimisticUpdate; + + if (!optimisticUpdate) { + if (invalidateQueries) { + const invalidator = createInvalidator( + model, + operation, + schema, + (predicate: InvalidationPredicate) => + invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + ); + // execute invalidator prior to user-provided onSuccess + result.onSuccess = async (...args) => { + await invalidator(...args); + const origOnSuccess: any = toValue(optionsValue?.onSuccess); + await origOnSuccess?.(...args); + }; + } + } else { + const optimisticUpdater = createOptimisticUpdater( model, operation, schema, - (predicate: InvalidationPredicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + { optimisticDataProvider: result.optimisticDataProvider }, + () => getAllQueries(queryClient), logging, ); - // execute invalidator prior to user-provided onSuccess - result.onSuccess = async (...args) => { - await invalidator(...args); - const origOnSuccess: any = toValue(optionsValue?.onSuccess); - await origOnSuccess?.(...args); - }; - } - } else { - const optimisticUpdater = createOptimisticUpdater( - model, - operation, - schema, - { optimisticDataProvider: result.optimisticDataProvider }, - () => getAllQueries(queryClient), - logging, - ); - - // optimistic update on mutate - const origOnMutate = result.onMutate; - result.onMutate = async (...args) => { - // execute optimistic updater prior to user-provided onMutate - await optimisticUpdater(...args); - - // call user-provided onMutate - return unref(origOnMutate)?.(...args); - }; - if (invalidateQueries) { - const invalidator = createInvalidator( - model, - operation, - schema, - (predicate: InvalidationPredicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), - logging, - ); - const origOnSettled = result.onSettled; - result.onSettled = async (...args) => { - // execute invalidator prior to user-provided onSettled - await invalidator(...args); + // optimistic update on mutate + const origOnMutate = result.onMutate; + result.onMutate = async (...args) => { + // execute optimistic updater prior to user-provided onMutate + await optimisticUpdater(...args); - // call user-provided onSettled - return unref(origOnSettled)?.(...args); + // call user-provided onMutate + return unref(origOnMutate)?.(...args); }; + + if (invalidateQueries) { + const invalidator = createInvalidator( + model, + operation, + schema, + (predicate: InvalidationPredicate) => + invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + ); + const origOnSettled = result.onSettled; + result.onSettled = async (...args) => { + // execute invalidator prior to user-provided onSettled + await invalidator(...args); + + // call user-provided onSettled + return unref(origOnSettled)?.(...args); + }; + } } } + return result; }); diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index fd32aa72..957b94ab 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -147,27 +147,27 @@ export abstract class BaseOperationHandler { }); } - protected async existsNonUnique( - kysely: ToKysely, - model: GetModels, - filter: any, - ): Promise { - const query = kysely.selectNoFrom((eb) => ( - eb.exists( - this.dialect - .buildSelectModel(model, model) - .select(sql.lit(1).as('$t')) - .where(() => this.dialect.buildFilter(model, model, filter)) - ).as('exists') - )).modifyEnd(this.makeContextComment({ model, operation: 'read' })); + protected async existsNonUnique(kysely: ToKysely, model: GetModels, filter: any): Promise { + const query = kysely + .selectNoFrom((eb) => + eb + .exists( + this.dialect + .buildSelectModel(model, model) + .select(sql.lit(1).as('$t')) + .where(() => this.dialect.buildFilter(model, model, filter)), + ) + .as('exists'), + ) + .modifyEnd(this.makeContextComment({ model, operation: 'read' })); let result: { exists: number | boolean }[] = []; const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId()); try { const r = await kysely.getExecutor().executeQuery(compiled); - result = r.rows as { exists: number | boolean}[]; + result = r.rows as { exists: number | boolean }[]; } catch (err) { - throw createDBQueryError('Failed to execute query', err, compiled.sql, compiled.parameters); + throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters); } return !!result[0]?.exists; diff --git a/packages/server/src/api/common/procedures.ts b/packages/server/src/api/common/procedures.ts index 60680158..a0d1e5d5 100644 --- a/packages/server/src/api/common/procedures.ts +++ b/packages/server/src/api/common/procedures.ts @@ -1,7 +1,7 @@ import { ORMError } from '@zenstackhq/orm'; import type { ProcedureDef, ProcedureParam, SchemaDef } from '@zenstackhq/orm/schema'; -export const PROCEDURE_ROUTE_PREFIXES = ['$procs'] as const; +export const PROCEDURE_ROUTE_PREFIXES = '$procs' as const; export function getProcedureDef(schema: SchemaDef, proc: string): ProcedureDef | undefined { const procs = schema.procedures ?? {}; diff --git a/samples/next.js/app/api/model/[...path]/route.ts b/samples/next.js/app/api/model/[...path]/route.ts index 49ba5ff5..0c1cfd81 100644 --- a/samples/next.js/app/api/model/[...path]/route.ts +++ b/samples/next.js/app/api/model/[...path]/route.ts @@ -4,7 +4,7 @@ import { RPCApiHandler } from '@zenstackhq/server/api'; import { NextRequestHandler } from '@zenstackhq/server/next'; const handler = NextRequestHandler({ - apiHandler: new RPCApiHandler({ schema }), + apiHandler: new RPCApiHandler({ schema, log: ['debug', 'error'] }), // fully open ZenStackClient is used here for demo purposes only, in a real application, // you should use one with access policies enabled getClient: () => db, diff --git a/samples/next.js/app/feeds/page.tsx b/samples/next.js/app/feeds/page.tsx new file mode 100644 index 00000000..b12f4b1d --- /dev/null +++ b/samples/next.js/app/feeds/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { schema } from '@/zenstack/schema-lite'; +import { useClientQueries } from '@zenstackhq/tanstack-query/react'; +import Link from 'next/link'; + +export default function FeedsPage() { + const clientQueries = useClientQueries(schema); + const { data: posts, isLoading, error } = clientQueries.$procs.listPublicPosts.useQuery(); + + return ( +
+

+ Public Feeds +

+ + + ← Back to Home + + + {isLoading &&
Loading public posts...
} + + {error && ( +
+ Error loading posts: {error instanceof Error ? error.message : 'Unknown error'} +
+ )} + + {!isLoading && !error && posts && posts.length === 0 && ( +
No public posts available yet.
+ )} + + {posts && posts.length > 0 && ( +
    + {posts.map((post) => ( +
  • +

    {post.title}

    +

    + Published on {new Date(post.createdAt).toLocaleDateString()} +

    +
  • + ))} +
+ )} + + {posts && posts.length > 0 && ( +
+ Showing {posts.length} public {posts.length === 1 ? 'post' : 'posts'} +
+ )} +
+ ); +} diff --git a/samples/next.js/app/layout.tsx b/samples/next.js/app/layout.tsx index 7a6da3a5..89d57397 100644 --- a/samples/next.js/app/layout.tsx +++ b/samples/next.js/app/layout.tsx @@ -1,4 +1,5 @@ import { Geist, Geist_Mono } from 'next/font/google'; +import Image from 'next/image'; import './globals.css'; import Providers from './providers'; @@ -20,7 +21,21 @@ export default function RootLayout({ return ( - {children} + +
+
+ Next.js logo + {children} +
+
+
); diff --git a/samples/next.js/app/page.tsx b/samples/next.js/app/page.tsx index 4372f2a2..9dee7770 100644 --- a/samples/next.js/app/page.tsx +++ b/samples/next.js/app/page.tsx @@ -4,7 +4,7 @@ import { Post } from '@/zenstack/models'; import { schema } from '@/zenstack/schema-lite'; import { FetchFn, useClientQueries } from '@zenstackhq/tanstack-query/react'; import { LoremIpsum } from 'lorem-ipsum'; -import Image from 'next/image'; +import Link from 'next/link'; import { useState } from 'react'; const lorem = new LoremIpsum({ wordsPerSentence: { max: 6, min: 4 } }); @@ -74,97 +74,107 @@ export default function Home() { } return ( -
-
- Next.js logo -
-

- My Awesome Blog -

- - - -
-
Current users
-
- {users?.map((user) => ( -
- {user.email} -
- ))} +
+

+ My Awesome Blog +

+ +
+ + View Public Feeds + + + Sign Up + +
+ + + +
+
Current users
+
+ {users?.map((user) => ( +
+ {user.email}
-
- -
- - - - - -
- -
    - {posts?.map((post) => ( -
  • -
    -
    -

    {post.title}

    - {post.$optimistic ? pending : null} -
    -
    - - -
    -
    - {post.$optimistic ? null : ( -

    - by {post.author.name} {!post.published ? '(Draft)' : ''} -

    - )} -
  • - ))} -
+ ))}
-
+
+ +
+ + + + + +
+ +
    + {posts?.map((post) => ( +
  • +
    +
    +

    {post.title}

    + {post.$optimistic ? pending : null} +
    +
    + + +
    +
    + {post.$optimistic ? null : ( +

    + by {post.author.name} {!post.published ? '(Draft)' : ''} +

    + )} +
  • + ))} +
); } diff --git a/samples/next.js/app/signup/page.tsx b/samples/next.js/app/signup/page.tsx new file mode 100644 index 00000000..ace8e621 --- /dev/null +++ b/samples/next.js/app/signup/page.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { schema } from '@/zenstack/schema-lite'; +import { useClientQueries } from '@zenstackhq/tanstack-query/react'; +import Link from 'next/link'; +import { FormEvent, useState } from 'react'; + +export default function SignupPage() { + const [email, setEmail] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const clientQueries = useClientQueries(schema); + const signUpMutation = clientQueries.$procs.signUp.useMutation(); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + setSuccessMessage(''); + setErrorMessage(''); + + signUpMutation.mutate( + { args: { email } }, + { + onSuccess: (user) => { + setSuccessMessage(`Successfully created user: ${user.email}`); + setEmail(''); + }, + onError: (error) => { + setErrorMessage(error instanceof Error ? error.message : 'Failed to sign up'); + }, + }, + ); + }; + + return ( +
+

Sign Up

+ + + ← Back to Home + + +
+
+ + setEmail(e.target.value)} + required + disabled={signUpMutation.isPending} + placeholder="user@example.com" + className="rounded-md border border-gray-300 px-4 py-2 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed dark:bg-zinc-900 dark:border-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+ + +
+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ ); +} diff --git a/samples/next.js/lib/db.ts b/samples/next.js/lib/db.ts index 688e7886..1ba1422a 100644 --- a/samples/next.js/lib/db.ts +++ b/samples/next.js/lib/db.ts @@ -1,10 +1,25 @@ import { schema } from '@/zenstack/schema'; import { ZenStackClient } from '@zenstackhq/orm'; +import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; export const db = new ZenStackClient(schema, { dialect: new SqliteDialect({ database: new SQLite('./zenstack/dev.db'), }), + procedures: { + signUp: ({ client, args }) => + client.user.create({ + data: { ...args }, + }), + listPublicPosts: ({ client }) => + client.post.findMany({ + where: { + published: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }), + }, }); diff --git a/samples/next.js/zenstack/input.ts b/samples/next.js/zenstack/input.ts index b2cd96ee..72c04fe5 100644 --- a/samples/next.js/zenstack/input.ts +++ b/samples/next.js/zenstack/input.ts @@ -6,11 +6,12 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserExistsArgs = $ExistsArgs<$Schema, "User">; export type UserCreateArgs = $CreateArgs<$Schema, "User">; export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; @@ -31,6 +32,7 @@ export type UserGetPayload; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostExistsArgs = $ExistsArgs<$Schema, "Post">; export type PostCreateArgs = $CreateArgs<$Schema, "Post">; export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; diff --git a/samples/next.js/zenstack/schema-lite.ts b/samples/next.js/zenstack/schema-lite.ts index 6153abe9..7308e184 100644 --- a/samples/next.js/zenstack/schema-lite.ts +++ b/samples/next.js/zenstack/schema-lite.ts @@ -101,6 +101,20 @@ export class SchemaType implements SchemaDef { } } as const; authType = "User" as const; + procedures = { + signUp: { + params: { + email: { name: "email", type: "String" } + }, + returnType: "User", + mutation: true + }, + listPublicPosts: { + params: {}, + returnType: "Post", + returnArray: true + } + } as const; plugins = {}; } export const schema = new SchemaType(); diff --git a/samples/next.js/zenstack/schema.ts b/samples/next.js/zenstack/schema.ts index c7d690ed..695ee053 100644 --- a/samples/next.js/zenstack/schema.ts +++ b/samples/next.js/zenstack/schema.ts @@ -110,6 +110,20 @@ export class SchemaType implements SchemaDef { } } as const; authType = "User" as const; + procedures = { + signUp: { + params: { + email: { name: "email", type: "String" } + }, + returnType: "User", + mutation: true + }, + listPublicPosts: { + params: {}, + returnType: "Post", + returnArray: true + } + } as const; plugins = {}; } export const schema = new SchemaType(); diff --git a/samples/next.js/zenstack/schema.zmodel b/samples/next.js/zenstack/schema.zmodel deleted file mode 100644 index e1775e12..00000000 --- a/samples/next.js/zenstack/schema.zmodel +++ /dev/null @@ -1,25 +0,0 @@ -datasource db { - provider = 'sqlite' - url = 'file:./dev.db' -} - -/// User model -model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique - name String? - posts Post[] -} - -/// Post model -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - title String - published Boolean @default(false) - author User @relation(fields: [authorId], references: [id], onUpdate: Cascade, onDelete: Cascade) - authorId String -} diff --git a/samples/next.js/zenstack/schema.zmodel b/samples/next.js/zenstack/schema.zmodel new file mode 120000 index 00000000..5baeec7a --- /dev/null +++ b/samples/next.js/zenstack/schema.zmodel @@ -0,0 +1 @@ +../../shared/schema.zmodel \ No newline at end of file diff --git a/samples/next.js/zenstack/seed.ts b/samples/next.js/zenstack/seed.ts index 6e02d80d..14004e7d 100644 --- a/samples/next.js/zenstack/seed.ts +++ b/samples/next.js/zenstack/seed.ts @@ -1,17 +1,7 @@ -import { ZenStackClient } from '@zenstackhq/orm'; -import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; -import { schema } from './schema'; +import { db } from '@/lib/db'; async function main() { - const db = new ZenStackClient(schema, { - dialect: new SqliteDialect({ - database: new SQLite('./zenstack/dev.db'), - }), - }); - await db.user.deleteMany(); - await db.user.createMany({ data: [ { id: '1', name: 'Alice', email: 'alice@example.com' }, diff --git a/samples/nuxt/app/app.vue b/samples/nuxt/app/app.vue index 4512f245..5e39f2ae 100644 --- a/samples/nuxt/app/app.vue +++ b/samples/nuxt/app/app.vue @@ -1,147 +1,5 @@ - - diff --git a/samples/nuxt/app/layouts/default.vue b/samples/nuxt/app/layouts/default.vue new file mode 100644 index 00000000..6a1f2ce6 --- /dev/null +++ b/samples/nuxt/app/layouts/default.vue @@ -0,0 +1,10 @@ + diff --git a/samples/nuxt/app/pages/feeds.vue b/samples/nuxt/app/pages/feeds.vue new file mode 100644 index 00000000..24169b00 --- /dev/null +++ b/samples/nuxt/app/pages/feeds.vue @@ -0,0 +1,52 @@ + + + diff --git a/samples/nuxt/app/pages/index.vue b/samples/nuxt/app/pages/index.vue new file mode 100644 index 00000000..068d2f96 --- /dev/null +++ b/samples/nuxt/app/pages/index.vue @@ -0,0 +1,155 @@ + + + diff --git a/samples/nuxt/app/pages/signup.vue b/samples/nuxt/app/pages/signup.vue new file mode 100644 index 00000000..72304f33 --- /dev/null +++ b/samples/nuxt/app/pages/signup.vue @@ -0,0 +1,80 @@ + + + diff --git a/samples/nuxt/app/plugins/tanstack-query.ts b/samples/nuxt/app/plugins/tanstack-query.ts index 2c380de0..3df30897 100644 --- a/samples/nuxt/app/plugins/tanstack-query.ts +++ b/samples/nuxt/app/plugins/tanstack-query.ts @@ -11,8 +11,8 @@ export default defineNuxtPlugin((nuxtApp) => { setup() { provideQuerySettingsContext({ endpoint: '/api/model', - logging: true + logging: true, }); - } + }, }); }); diff --git a/samples/nuxt/server/api/model/[...].ts b/samples/nuxt/server/api/model/[...].ts index 7483280a..7e797d03 100644 --- a/samples/nuxt/server/api/model/[...].ts +++ b/samples/nuxt/server/api/model/[...].ts @@ -4,7 +4,7 @@ import { db } from '~~/server/utils/db'; import { schema } from '~~/zenstack/schema'; const handler = createEventHandler({ - apiHandler: new RPCApiHandler({ schema }), + apiHandler: new RPCApiHandler({ schema, log: ['debug', 'error'] }), // fully open ZenStackClient is used here for demo purposes only, in a real application, // you should use one with access policies enabled getClient: () => db, diff --git a/samples/nuxt/server/utils/db.ts b/samples/nuxt/server/utils/db.ts index 86aa5248..5513bc00 100644 --- a/samples/nuxt/server/utils/db.ts +++ b/samples/nuxt/server/utils/db.ts @@ -1,10 +1,25 @@ import { ZenStackClient } from '@zenstackhq/orm'; +import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; -import { schema } from '~~/zenstack/schema'; +import { schema } from '../../zenstack/schema'; export const db = new ZenStackClient(schema, { dialect: new SqliteDialect({ database: new SQLite('./zenstack/dev.db'), }), + procedures: { + signUp: ({ client, args }) => + client.user.create({ + data: { ...args }, + }), + listPublicPosts: ({ client }) => + client.post.findMany({ + where: { + published: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }), + }, }); diff --git a/samples/nuxt/zenstack/input.ts b/samples/nuxt/zenstack/input.ts index b2cd96ee..72c04fe5 100644 --- a/samples/nuxt/zenstack/input.ts +++ b/samples/nuxt/zenstack/input.ts @@ -6,11 +6,12 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserExistsArgs = $ExistsArgs<$Schema, "User">; export type UserCreateArgs = $CreateArgs<$Schema, "User">; export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; @@ -31,6 +32,7 @@ export type UserGetPayload; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostExistsArgs = $ExistsArgs<$Schema, "Post">; export type PostCreateArgs = $CreateArgs<$Schema, "Post">; export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; diff --git a/samples/nuxt/zenstack/schema-lite.ts b/samples/nuxt/zenstack/schema-lite.ts index 6153abe9..7308e184 100644 --- a/samples/nuxt/zenstack/schema-lite.ts +++ b/samples/nuxt/zenstack/schema-lite.ts @@ -101,6 +101,20 @@ export class SchemaType implements SchemaDef { } } as const; authType = "User" as const; + procedures = { + signUp: { + params: { + email: { name: "email", type: "String" } + }, + returnType: "User", + mutation: true + }, + listPublicPosts: { + params: {}, + returnType: "Post", + returnArray: true + } + } as const; plugins = {}; } export const schema = new SchemaType(); diff --git a/samples/nuxt/zenstack/schema.ts b/samples/nuxt/zenstack/schema.ts index c7d690ed..695ee053 100644 --- a/samples/nuxt/zenstack/schema.ts +++ b/samples/nuxt/zenstack/schema.ts @@ -110,6 +110,20 @@ export class SchemaType implements SchemaDef { } } as const; authType = "User" as const; + procedures = { + signUp: { + params: { + email: { name: "email", type: "String" } + }, + returnType: "User", + mutation: true + }, + listPublicPosts: { + params: {}, + returnType: "Post", + returnArray: true + } + } as const; plugins = {}; } export const schema = new SchemaType(); diff --git a/samples/nuxt/zenstack/schema.zmodel b/samples/nuxt/zenstack/schema.zmodel deleted file mode 100644 index e1775e12..00000000 --- a/samples/nuxt/zenstack/schema.zmodel +++ /dev/null @@ -1,25 +0,0 @@ -datasource db { - provider = 'sqlite' - url = 'file:./dev.db' -} - -/// User model -model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique - name String? - posts Post[] -} - -/// Post model -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - title String - published Boolean @default(false) - author User @relation(fields: [authorId], references: [id], onUpdate: Cascade, onDelete: Cascade) - authorId String -} diff --git a/samples/nuxt/zenstack/schema.zmodel b/samples/nuxt/zenstack/schema.zmodel new file mode 120000 index 00000000..5baeec7a --- /dev/null +++ b/samples/nuxt/zenstack/schema.zmodel @@ -0,0 +1 @@ +../../shared/schema.zmodel \ No newline at end of file diff --git a/samples/nuxt/zenstack/seed.ts b/samples/nuxt/zenstack/seed.ts index 6e02d80d..7a4acb63 100644 --- a/samples/nuxt/zenstack/seed.ts +++ b/samples/nuxt/zenstack/seed.ts @@ -1,17 +1,7 @@ -import { ZenStackClient } from '@zenstackhq/orm'; -import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; -import { schema } from './schema'; +import { db } from '../server/utils/db'; async function main() { - const db = new ZenStackClient(schema, { - dialect: new SqliteDialect({ - database: new SQLite('./zenstack/dev.db'), - }), - }); - await db.user.deleteMany(); - await db.user.createMany({ data: [ { id: '1', name: 'Alice', email: 'alice@example.com' }, diff --git a/samples/shared/schema.zmodel b/samples/shared/schema.zmodel new file mode 100644 index 00000000..a0d25d8a --- /dev/null +++ b/samples/shared/schema.zmodel @@ -0,0 +1,28 @@ +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +/// User model +model User { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + name String? + posts Post[] +} + +/// Post model +model Post { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id], onUpdate: Cascade, onDelete: Cascade) + authorId String +} + +mutation procedure signUp(email: String): User +procedure listPublicPosts(): Post[] diff --git a/samples/sveltekit/src/app.d.ts b/samples/sveltekit/src/app.d.ts index da08e6da..520c4217 100644 --- a/samples/sveltekit/src/app.d.ts +++ b/samples/sveltekit/src/app.d.ts @@ -1,13 +1,13 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } } export {}; diff --git a/samples/sveltekit/src/lib/db.ts b/samples/sveltekit/src/lib/db.ts index bbf70b8c..ab4727df 100644 --- a/samples/sveltekit/src/lib/db.ts +++ b/samples/sveltekit/src/lib/db.ts @@ -7,4 +7,19 @@ export const db = new ZenStackClient(schema, { dialect: new SqliteDialect({ database: new SQLite("./src/zenstack/dev.db"), }), + procedures: { + signUp: ({ client, args }) => + client.user.create({ + data: { ...args }, + }), + listPublicPosts: ({ client }) => + client.post.findMany({ + where: { + published: true, + }, + orderBy: { + updatedAt: "desc", + }, + }), + }, }); diff --git a/samples/sveltekit/src/routes/+layout.svelte b/samples/sveltekit/src/routes/+layout.svelte index 77a8e800..b501fc52 100644 --- a/samples/sveltekit/src/routes/+layout.svelte +++ b/samples/sveltekit/src/routes/+layout.svelte @@ -1,23 +1,34 @@ - + - {@render children()} +
+
+ SvelteKit logo + {@render children()} +
+
diff --git a/samples/sveltekit/src/routes/+page.svelte b/samples/sveltekit/src/routes/+page.svelte index 5725bf79..4f479b42 100644 --- a/samples/sveltekit/src/routes/+page.svelte +++ b/samples/sveltekit/src/routes/+page.svelte @@ -19,15 +19,14 @@ const clientQueries = useClientQueries(schema, () => ({ fetch: customFetch })); const users = clientQueries.user.useFindMany(); - const posts = - clientQueries.post.useFindMany(() => ( - { - where: showPublishedOnly ? { published: true } : undefined, - orderBy: { createdAt: 'desc' }, - include: { author: true } - }), - () => ({ enabled: enableFetch }) - ) + const posts = clientQueries.post.useFindMany( + () => ({ + where: showPublishedOnly ? { published: true } : undefined, + orderBy: { createdAt: 'desc' }, + include: { author: true } + }), + () => ({ enabled: enableFetch }) + ); const createPost = clientQueries.post.useCreate(() => ({ optimisticUpdate: optimistic })); const deletePost = clientQueries.post.useDelete(() => ({ optimisticUpdate: optimistic })); @@ -70,100 +69,104 @@ {#if users.isFetched && (!users.data || users.data.length === 0)}
No users found. Please run "pnpm db:init" to seed the database.
{:else} -
-
+

- SvelteKit logo -
-

- My Awesome Blog -

- - - -
-
Current users
-
- {#if users.isLoading} -
Loading users...
- {:else if users.isError} -
Error loading users: {users.error.message}
- {:else} - {#each users.data as user} -
- {user.email} -
- {/each} - {/if} -
-
- -
- - - - - -
- -
    - {#if posts.data} - {#each posts.data as post} -
  • -
    -
    -

    {post.title}

    - {#if post.$optimistic} - pending - {/if} -
    -
    - - -
    -
    - {#if !post.$optimistic} -

    - by {post.author.name} - {!post.published ? '(Draft)' : ''} -

    - {/if} -
  • - {/each} - {/if} -
+ My Awesome Blog +

+ + + + + +
+
Current users
+
+ {#if users.isLoading} +
Loading users...
+ {:else if users.isError} +
Error loading users: {users.error.message}
+ {:else} + {#each users.data as user} +
+ {user.email} +
+ {/each} + {/if}
-
+
+ +
+ + + + + +
+ +
    + {#if posts.data} + {#each posts.data as post} +
  • +
    +
    +

    {post.title}

    + {#if post.$optimistic} + pending + {/if} +
    +
    + + +
    +
    + {#if !post.$optimistic} +

    + by {post.author.name} + {!post.published ? '(Draft)' : ''} +

    + {/if} +
  • + {/each} + {/if} +
{/if} diff --git a/samples/sveltekit/src/routes/feeds/+page.svelte b/samples/sveltekit/src/routes/feeds/+page.svelte new file mode 100644 index 00000000..c84617b4 --- /dev/null +++ b/samples/sveltekit/src/routes/feeds/+page.svelte @@ -0,0 +1,57 @@ + + +
+

+ Public Feeds +

+ + + ← Back to Home + + + {#if isLoading} +
Loading public posts...
+ {/if} + + {#if error} +
+ Error loading posts: {error instanceof Error ? error.message : 'Unknown error'} +
+ {/if} + + {#if !isLoading && !error && posts && posts.length === 0} +
No public posts available yet.
+ {/if} + + {#if posts && posts.length > 0} +
    + {#each posts as post} +
  • +

    {post.title}

    +

    + Published on {new Date(post.createdAt).toLocaleDateString()} +

    +
  • + {/each} +
+ {/if} + + {#if posts && posts.length > 0} +
+ Showing {posts.length} public {posts.length === 1 ? 'post' : 'posts'} +
+ {/if} +
diff --git a/samples/sveltekit/src/routes/signup/+page.svelte b/samples/sveltekit/src/routes/signup/+page.svelte new file mode 100644 index 00000000..25efb0e7 --- /dev/null +++ b/samples/sveltekit/src/routes/signup/+page.svelte @@ -0,0 +1,86 @@ + + +
+

+ Sign Up +

+ + + ← Back to Home + + +
+
+ + +
+ + +
+ + {#if successMessage} +
+ {successMessage} +
+ {/if} + + {#if errorMessage} +
+ {errorMessage} +
+ {/if} +
diff --git a/samples/sveltekit/src/zenstack/input.ts b/samples/sveltekit/src/zenstack/input.ts index b2cd96ee..37e85b3e 100644 --- a/samples/sveltekit/src/zenstack/input.ts +++ b/samples/sveltekit/src/zenstack/input.ts @@ -6,17 +6,49 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; -import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; +import type { + FindManyArgs as $FindManyArgs, + FindUniqueArgs as $FindUniqueArgs, + FindFirstArgs as $FindFirstArgs, + ExistsArgs as $ExistsArgs, + CreateArgs as $CreateArgs, + CreateManyArgs as $CreateManyArgs, + CreateManyAndReturnArgs as $CreateManyAndReturnArgs, + UpdateArgs as $UpdateArgs, + UpdateManyArgs as $UpdateManyArgs, + UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, + UpsertArgs as $UpsertArgs, + DeleteArgs as $DeleteArgs, + DeleteManyArgs as $DeleteManyArgs, + CountArgs as $CountArgs, + AggregateArgs as $AggregateArgs, + GroupByArgs as $GroupByArgs, + WhereInput as $WhereInput, + SelectInput as $SelectInput, + IncludeInput as $IncludeInput, + OmitInput as $OmitInput, + QueryOptions as $QueryOptions, +} from "@zenstackhq/orm"; +import type { + SimplifiedPlainResult as $Result, + SelectIncludeOmit as $SelectIncludeOmit, +} from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserExistsArgs = $ExistsArgs<$Schema, "User">; export type UserCreateArgs = $CreateArgs<$Schema, "User">; export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; -export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs< + $Schema, + "User" +>; export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; -export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs< + $Schema, + "User" +>; export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; @@ -27,16 +59,26 @@ export type UserWhereInput = $WhereInput<$Schema, "User">; export type UserSelect = $SelectInput<$Schema, "User">; export type UserInclude = $IncludeInput<$Schema, "User">; export type UserOmit = $OmitInput<$Schema, "User">; -export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; +export type UserGetPayload< + Args extends $SelectIncludeOmit<$Schema, "User", true>, + Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>, +> = $Result<$Schema, "User", Args, Options>; export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostExistsArgs = $ExistsArgs<$Schema, "Post">; export type PostCreateArgs = $CreateArgs<$Schema, "Post">; export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; -export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; +export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs< + $Schema, + "Post" +>; export type PostUpdateArgs = $UpdateArgs<$Schema, "Post">; export type PostUpdateManyArgs = $UpdateManyArgs<$Schema, "Post">; -export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Post">; +export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs< + $Schema, + "Post" +>; export type PostUpsertArgs = $UpsertArgs<$Schema, "Post">; export type PostDeleteArgs = $DeleteArgs<$Schema, "Post">; export type PostDeleteManyArgs = $DeleteManyArgs<$Schema, "Post">; @@ -47,4 +89,7 @@ export type PostWhereInput = $WhereInput<$Schema, "Post">; export type PostSelect = $SelectInput<$Schema, "Post">; export type PostInclude = $IncludeInput<$Schema, "Post">; export type PostOmit = $OmitInput<$Schema, "Post">; -export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; +export type PostGetPayload< + Args extends $SelectIncludeOmit<$Schema, "Post", true>, + Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>, +> = $Result<$Schema, "Post", Args, Options>; diff --git a/samples/sveltekit/src/zenstack/schema-lite.ts b/samples/sveltekit/src/zenstack/schema-lite.ts index 6153abe9..5b73135f 100644 --- a/samples/sveltekit/src/zenstack/schema-lite.ts +++ b/samples/sveltekit/src/zenstack/schema-lite.ts @@ -7,100 +7,118 @@ import { type SchemaDef, ExpressionUtils } from "@zenstackhq/orm/schema"; export class SchemaType implements SchemaDef { - provider = { - type: "sqlite" - } as const; - models = { - User: { - name: "User", - fields: { - id: { - name: "id", - type: "String", - id: true, - default: ExpressionUtils.call("cuid") - }, - createdAt: { - name: "createdAt", - type: "DateTime", - default: ExpressionUtils.call("now") - }, - updatedAt: { - name: "updatedAt", - type: "DateTime", - updatedAt: true - }, - email: { - name: "email", - type: "String", - unique: true - }, - name: { - name: "name", - type: "String", - optional: true - }, - posts: { - name: "posts", - type: "Post", - array: true, - relation: { opposite: "author" } - } - }, - idFields: ["id"], - uniqueFields: { - id: { type: "String" }, - email: { type: "String" } - } + provider = { + type: "sqlite", + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid"), }, - Post: { - name: "Post", - fields: { - id: { - name: "id", - type: "String", - id: true, - default: ExpressionUtils.call("cuid") - }, - createdAt: { - name: "createdAt", - type: "DateTime", - default: ExpressionUtils.call("now") - }, - updatedAt: { - name: "updatedAt", - type: "DateTime", - updatedAt: true - }, - title: { - name: "title", - type: "String" - }, - published: { - name: "published", - type: "Boolean", - default: false - }, - author: { - name: "author", - type: "User", - relation: { opposite: "posts", fields: ["authorId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } - }, - authorId: { - name: "authorId", - type: "String", - foreignKeyFor: [ - "author" - ] - } - }, - idFields: ["id"], - uniqueFields: { - id: { type: "String" } - } - } - } as const; - authType = "User" as const; - plugins = {}; + createdAt: { + name: "createdAt", + type: "DateTime", + default: ExpressionUtils.call("now"), + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + }, + email: { + name: "email", + type: "String", + unique: true, + }, + name: { + name: "name", + type: "String", + optional: true, + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" }, + }, + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" }, + }, + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid"), + }, + createdAt: { + name: "createdAt", + type: "DateTime", + default: ExpressionUtils.call("now"), + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + }, + title: { + name: "title", + type: "String", + }, + published: { + name: "published", + type: "Boolean", + default: false, + }, + author: { + name: "author", + type: "User", + relation: { + opposite: "posts", + fields: ["authorId"], + references: ["id"], + onUpdate: "Cascade", + onDelete: "Cascade", + }, + }, + authorId: { + name: "authorId", + type: "String", + foreignKeyFor: ["author"], + }, + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + }, + }, + } as const; + authType = "User" as const; + procedures = { + signUp: { + params: { + email: { name: "email", type: "String" }, + }, + returnType: "User", + mutation: true, + }, + listPublicPosts: { + params: {}, + returnType: "Post", + returnArray: true, + }, + } as const; + plugins = {}; } export const schema = new SchemaType(); diff --git a/samples/sveltekit/src/zenstack/schema.ts b/samples/sveltekit/src/zenstack/schema.ts index c7d690ed..27bc05c2 100644 --- a/samples/sveltekit/src/zenstack/schema.ts +++ b/samples/sveltekit/src/zenstack/schema.ts @@ -7,109 +7,172 @@ import { type SchemaDef, ExpressionUtils } from "@zenstackhq/orm/schema"; export class SchemaType implements SchemaDef { - provider = { - type: "sqlite" - } as const; - models = { - User: { - name: "User", - fields: { - id: { - name: "id", - type: "String", - id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") - }, - createdAt: { - name: "createdAt", - type: "DateTime", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], - default: ExpressionUtils.call("now") - }, - updatedAt: { - name: "updatedAt", - type: "DateTime", - updatedAt: true, - attributes: [{ name: "@updatedAt" }] - }, - email: { - name: "email", - type: "String", - unique: true, - attributes: [{ name: "@unique" }] - }, - name: { - name: "name", - type: "String", - optional: true - }, - posts: { - name: "posts", - type: "Post", - array: true, - relation: { opposite: "author" } - } + provider = { + type: "sqlite", + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [ + { name: "@id" }, + { + name: "@default", + args: [{ name: "value", value: ExpressionUtils.call("cuid") }], }, - idFields: ["id"], - uniqueFields: { - id: { type: "String" }, - email: { type: "String" } - } + ], + default: ExpressionUtils.call("cuid"), }, - Post: { - name: "Post", - fields: { - id: { - name: "id", - type: "String", - id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") - }, - createdAt: { - name: "createdAt", - type: "DateTime", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], - default: ExpressionUtils.call("now") - }, - updatedAt: { - name: "updatedAt", - type: "DateTime", - updatedAt: true, - attributes: [{ name: "@updatedAt" }] - }, - title: { - name: "title", - type: "String" - }, - published: { - name: "published", - type: "Boolean", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }], - default: false + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [ + { + name: "@default", + args: [{ name: "value", value: ExpressionUtils.call("now") }], + }, + ], + default: ExpressionUtils.call("now"), + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }], + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@unique" }], + }, + name: { + name: "name", + type: "String", + optional: true, + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" }, + }, + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" }, + }, + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [ + { name: "@id" }, + { + name: "@default", + args: [{ name: "value", value: ExpressionUtils.call("cuid") }], + }, + ], + default: ExpressionUtils.call("cuid"), + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [ + { + name: "@default", + args: [{ name: "value", value: ExpressionUtils.call("now") }], + }, + ], + default: ExpressionUtils.call("now"), + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }], + }, + title: { + name: "title", + type: "String", + }, + published: { + name: "published", + type: "Boolean", + attributes: [ + { + name: "@default", + args: [{ name: "value", value: ExpressionUtils.literal(false) }], + }, + ], + default: false, + }, + author: { + name: "author", + type: "User", + attributes: [ + { + name: "@relation", + args: [ + { + name: "fields", + value: ExpressionUtils.array([ + ExpressionUtils.field("authorId"), + ]), }, - author: { - name: "author", - type: "User", - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], - relation: { opposite: "posts", fields: ["authorId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } + { + name: "references", + value: ExpressionUtils.array([ExpressionUtils.field("id")]), }, - authorId: { - name: "authorId", - type: "String", - foreignKeyFor: [ - "author" - ] - } + { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, + { name: "onDelete", value: ExpressionUtils.literal("Cascade") }, + ], }, - idFields: ["id"], - uniqueFields: { - id: { type: "String" } - } - } - } as const; - authType = "User" as const; - plugins = {}; + ], + relation: { + opposite: "posts", + fields: ["authorId"], + references: ["id"], + onUpdate: "Cascade", + onDelete: "Cascade", + }, + }, + authorId: { + name: "authorId", + type: "String", + foreignKeyFor: ["author"], + }, + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + }, + }, + } as const; + authType = "User" as const; + procedures = { + signUp: { + params: { + email: { name: "email", type: "String" }, + }, + returnType: "User", + mutation: true, + }, + listPublicPosts: { + params: {}, + returnType: "Post", + returnArray: true, + }, + } as const; + plugins = {}; } export const schema = new SchemaType(); From b6d58c5706f00889daa4b85c4d97a6b072ea5ec9 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:47:27 +0800 Subject: [PATCH 13/14] add missing file --- samples/sveltekit/src/zenstack/schema.zmodel | 26 +------------------- 1 file changed, 1 insertion(+), 25 deletions(-) mode change 100644 => 120000 samples/sveltekit/src/zenstack/schema.zmodel diff --git a/samples/sveltekit/src/zenstack/schema.zmodel b/samples/sveltekit/src/zenstack/schema.zmodel deleted file mode 100644 index e1775e12..00000000 --- a/samples/sveltekit/src/zenstack/schema.zmodel +++ /dev/null @@ -1,25 +0,0 @@ -datasource db { - provider = 'sqlite' - url = 'file:./dev.db' -} - -/// User model -model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique - name String? - posts Post[] -} - -/// Post model -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - title String - published Boolean @default(false) - author User @relation(fields: [authorId], references: [id], onUpdate: Cascade, onDelete: Cascade) - authorId String -} diff --git a/samples/sveltekit/src/zenstack/schema.zmodel b/samples/sveltekit/src/zenstack/schema.zmodel new file mode 120000 index 00000000..67e69c0f --- /dev/null +++ b/samples/sveltekit/src/zenstack/schema.zmodel @@ -0,0 +1 @@ +../../../shared/schema.zmodel \ No newline at end of file From ecebbe5886ebefaac748d6f8fd737fd3012e839c Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:56:43 +0800 Subject: [PATCH 14/14] fix formatting --- samples/sveltekit/src/routes/+layout.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/samples/sveltekit/src/routes/+layout.svelte b/samples/sveltekit/src/routes/+layout.svelte index b501fc52..e1770a85 100644 --- a/samples/sveltekit/src/routes/+layout.svelte +++ b/samples/sveltekit/src/routes/+layout.svelte @@ -16,9 +16,7 @@ setQuerySettingsContext({ endpoint: "/api/model", logging: true }); - +