From 9b95c290f8049433c7e28b77f7d819a08a452d73 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Tue, 30 Dec 2025 22:26:17 +0300 Subject: [PATCH 01/13] 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 39540625b..7c81f4251 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 c41a99ea4..b5e99df22 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 c2307fa1d..0d663044c 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/13] 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 ba5f04d5d..df889c6bd 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/13] 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 b5e99df22..c48c4aa51 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/13] 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 c48c4aa51..aefb9083d 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/13] 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 dc1a6f836..072e34b6f 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 a213c1d82..f5a8945c1 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 000000000..4a76e31f4 --- /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/13] 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 072e34b6f..8b16a9de1 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/13] 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 aefb9083d..ba8fd1585 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/13] 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 07c4fff56..146609fb7 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/13] 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 ba8fd1585..16e3826c7 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/13] 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 000000000..86b0e379f --- /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/13] 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 86b0e379f..f16091e5c 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 b3bb612702bcd2d002a9efab1b6a0fb3855015d4 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:14:41 +0800 Subject: [PATCH 12/13] fix(qaas): add options validation --- packages/server/src/api/common/procedures.ts | 2 +- packages/server/src/api/common/schemas.ts | 3 + packages/server/src/api/rest/index.ts | 56 +- packages/server/src/api/rpc/index.ts | 47 +- .../test/api/options-validation.test.ts | 600 ++++++++++++++++++ 5 files changed, 672 insertions(+), 36 deletions(-) create mode 100644 packages/server/src/api/common/schemas.ts create mode 100644 packages/server/test/api/options-validation.test.ts diff --git a/packages/server/src/api/common/procedures.ts b/packages/server/src/api/common/procedures.ts index 60680158a..a0d1e5d55 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/packages/server/src/api/common/schemas.ts b/packages/server/src/api/common/schemas.ts new file mode 100644 index 000000000..44e30665c --- /dev/null +++ b/packages/server/src/api/common/schemas.ts @@ -0,0 +1,3 @@ +import z from 'zod'; + +export const loggerSchema = z.union([z.enum(['debug', 'info', 'warn', 'error']).array(), z.function()]); diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1b5580afc..ef72185f4 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -7,15 +7,12 @@ import tsjapi, { type Linker, type Paginator, type Relator, type Serializer, typ import { match } from 'ts-pattern'; import UrlPattern from 'url-pattern'; import z from 'zod'; +import { fromError } from 'zod-validation-error/v4'; import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types'; +import { getProcedureDef, mapProcedureArgs } from '../common/procedures'; +import { loggerSchema } from '../common/schemas'; +import { processSuperJsonRequestPayload } from '../common/utils'; import { getZodErrorMessage, log, registerCustomSerializers } from '../utils'; -import { - getProcedureDef, - mapProcedureArgs, -} from '../common/procedures'; -import { - processSuperJsonRequestPayload, -} from '../common/utils'; /** * Options for {@link RestApiHandler} @@ -58,8 +55,14 @@ export type RestApiHandlerOptions = { */ urlSegmentCharset?: string; + /** + * Mapping from model names to URL segment names. + */ modelNameMapping?: Record; + /** + * Mapping from model names to unique field name to be used as resource's ID. + */ externalIdMapping?: Record; }; @@ -260,6 +263,8 @@ export class RestApiHandler implements Api private externalIdMapping: Record; constructor(private readonly options: RestApiHandlerOptions) { + this.validateOptions(options); + this.idDivider = options.idDivider ?? DEFAULT_ID_DIVIDER; const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; @@ -282,6 +287,23 @@ export class RestApiHandler implements Api this.buildSerializers(); } + private validateOptions(options: RestApiHandlerOptions) { + const schema = z.strictObject({ + schema: z.object(), + log: loggerSchema.optional(), + endpoint: z.string(), + pageSize: z.number().positive().optional(), + idDivider: z.string().min(1).optional(), + urlSegmentCharset: z.string().min(1).optional(), + modelNameMapping: z.record(z.string(), z.string()).optional(), + externalIdMapping: z.record(z.string(), z.string()).optional(), + }); + const parseResult = schema.safeParse(options); + if (!parseResult.success) { + throw new Error(`Invalid options: ${fromError(parseResult.error)}`); + } + } + get schema() { return this.options.schema; } @@ -530,7 +552,9 @@ export class RestApiHandler implements Api try { procInput = mapProcedureArgs(procDef, processedArgsPayload); } catch (err) { - return this.makeProcBadInputErrorResponse(err instanceof Error ? err.message : 'invalid procedure arguments'); + return this.makeProcBadInputErrorResponse( + err instanceof Error ? err.message : 'invalid procedure arguments', + ); } try { @@ -926,16 +950,16 @@ export class RestApiHandler implements Api prev: offset - limit >= 0 && offset - limit <= total - 1 ? this.replaceURLSearchParams(baseUrl, { - 'page[offset]': offset - limit, - 'page[limit]': limit, - }) + 'page[offset]': offset - limit, + 'page[limit]': limit, + }) : null, next: offset + limit <= total - 1 ? this.replaceURLSearchParams(baseUrl, { - 'page[offset]': offset + limit, - 'page[limit]': limit, - }) + 'page[offset]': offset + limit, + 'page[limit]': limit, + }) : null, })); } @@ -2001,8 +2025,8 @@ export class RestApiHandler implements Api } else { currPayload[relation] = select ? { - select: { ...select }, - } + select: { ...select }, + } : true; } } diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index 6e094c37e..6f572e82d 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -3,16 +3,13 @@ import { ORMError, ORMErrorReason, type ClientContract } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; import SuperJSON from 'superjson'; import { match } from 'ts-pattern'; +import z from 'zod'; +import { fromError } from 'zod-validation-error/v4'; import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types'; +import { getProcedureDef, mapProcedureArgs, PROCEDURE_ROUTE_PREFIXES } from '../common/procedures'; +import { loggerSchema } from '../common/schemas'; +import { processSuperJsonRequestPayload, unmarshalQ } from '../common/utils'; import { log, registerCustomSerializers } from '../utils'; -import { - getProcedureDef, - mapProcedureArgs, -} from '../common/procedures'; -import { - processSuperJsonRequestPayload, - unmarshalQ, -} from '../common/utils'; registerCustomSerializers(); @@ -35,7 +32,17 @@ export type RPCApiHandlerOptions = { * RPC style API request handler that mirrors the ZenStackClient API */ export class RPCApiHandler implements ApiHandler { - constructor(private readonly options: RPCApiHandlerOptions) { } + constructor(private readonly options: RPCApiHandlerOptions) { + this.validateOptions(options); + } + + private validateOptions(options: RPCApiHandlerOptions) { + const schema = z.strictObject({ schema: z.object(), log: loggerSchema.optional() }); + const parseResult = schema.safeParse(options); + if (!parseResult.success) { + throw new Error(`Invalid options: ${fromError(parseResult.error)}`); + } + } get schema(): Schema { return this.options.schema; @@ -54,7 +61,7 @@ export class RPCApiHandler implements ApiH return this.makeBadInputErrorResponse('invalid request path'); } - if (model === '$procs') { + if (model === PROCEDURE_ROUTE_PREFIXES) { return this.handleProcedureRequest({ client, method: method.toUpperCase(), @@ -96,9 +103,7 @@ export class RPCApiHandler implements ApiH return this.makeBadInputErrorResponse('invalid request method, only GET is supported'); } try { - args = query?.['q'] - ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) - : {}; + args = query?.['q'] ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) : {}; } catch { return this.makeBadInputErrorResponse('invalid "q" query parameter'); } @@ -123,9 +128,7 @@ export class RPCApiHandler implements ApiH return this.makeBadInputErrorResponse('invalid request method, only DELETE is supported'); } try { - args = query?.['q'] - ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) - : {}; + args = query?.['q'] ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) : {}; } catch (err) { return this.makeBadInputErrorResponse( err instanceof Error ? err.message : 'invalid "q" query parameter', @@ -223,7 +226,9 @@ export class RPCApiHandler implements ApiH ? unmarshalQ(query['q'] as string, query['meta'] as string | undefined) : undefined; } catch (err) { - return this.makeBadInputErrorResponse(err instanceof Error ? err.message : 'invalid "q" query parameter'); + return this.makeBadInputErrorResponse( + err instanceof Error ? err.message : 'invalid "q" query parameter', + ); } } @@ -251,7 +256,11 @@ export class RPCApiHandler implements ApiH } const response = { status: 200, body: responseBody }; - log(this.options.log, 'debug', () => `sending response for "$procs.${proc}" request: ${safeJSONStringify(response)}`); + log( + this.options.log, + 'debug', + () => `sending response for "$procs.${proc}" request: ${safeJSONStringify(response)}`, + ); return response; } catch (err) { log(this.options.log, 'error', `error occurred when handling "$procs.${proc}" request`, err); @@ -312,7 +321,7 @@ export class RPCApiHandler implements ApiH status = 400; error.dbErrorCode = err.dbErrorCode; }) - .otherwise(() => { }); + .otherwise(() => {}); const resp = { status, body: { error } }; log(this.options.log, 'debug', () => `sending error response: ${safeJSONStringify(resp)}`); diff --git a/packages/server/test/api/options-validation.test.ts b/packages/server/test/api/options-validation.test.ts new file mode 100644 index 000000000..63e81b4de --- /dev/null +++ b/packages/server/test/api/options-validation.test.ts @@ -0,0 +1,600 @@ +import { ClientContract } from '@zenstackhq/orm'; +import { SchemaDef } from '@zenstackhq/orm/schema'; +import { createTestClient } from '@zenstackhq/testtools'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { RestApiHandler } from '../../src/api/rest'; +import { RPCApiHandler } from '../../src/api/rpc'; + +describe('API Handler Options Validation', () => { + let client: ClientContract; + + const testSchema = ` + model User { + id String @id @default(cuid()) + email String @unique + name String + } + `; + + beforeEach(async () => { + client = await createTestClient(testSchema); + }); + + describe('RestApiHandler Options Validation', () => { + it('should accept valid options', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + }).not.toThrow(); + }); + + it('should accept valid options with all optional fields', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: ['debug', 'info', 'warn', 'error'], + pageSize: 50, + idDivider: '-', + urlSegmentCharset: 'a-zA-Z0-9-_~', + modelNameMapping: { User: 'users' }, + }); + }).not.toThrow(); + }); + + it('should accept custom log function', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: (level: string, message: string) => { + console.log(`[${level}] ${message}`); + }, + }); + }).not.toThrow(); + }); + + it('should throw error when schema is missing', () => { + expect(() => { + new RestApiHandler({ + endpoint: 'http://localhost/api', + } as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when endpoint is missing', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + } as any); + }).toThrow('Invalid options'); + }); + + it('should accept empty string endpoint', () => { + // Note: Zod z.string() validation allows empty strings + // The endpoint validation doesn't enforce non-empty string + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: '', + }); + }).not.toThrow(); + }); + + it('should throw error when endpoint is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 123, + } as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when pageSize is not a number', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when pageSize is zero', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 0, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when pageSize is negative', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: -10, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when idDivider is empty string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: '', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when idDivider is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: 123 as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when urlSegmentCharset is empty string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + urlSegmentCharset: '', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when urlSegmentCharset is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + urlSegmentCharset: [] as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when modelNameMapping is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when modelNameMapping values are not strings', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: { User: 123 } as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when externalIdMapping is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when externalIdMapping values are not strings', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: { User: 123 } as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is invalid type', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log array contains invalid values', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: ['debug', 'invalid'] as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: 'invalid' as any, + endpoint: 'http://localhost/api', + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is null', () => { + expect(() => { + new RestApiHandler({ + schema: null as any, + endpoint: 'http://localhost/api', + }); + }).toThrow('Invalid options'); + }); + + it('should accept valid pageSize of 1', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 1, + }); + }).not.toThrow(); + }); + + it('should accept large pageSize', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 10000, + }); + }).not.toThrow(); + }); + + it('should accept single character idDivider', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: '|', + }); + }).not.toThrow(); + }); + + it('should accept multi-character idDivider', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + idDivider: '---', + }); + }).not.toThrow(); + }); + }); + + describe('RPCApiHandler Options Validation', () => { + it('should accept valid options', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + }); + }).not.toThrow(); + }); + + it('should accept valid options with log array', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: ['debug', 'info', 'warn', 'error'], + }); + }).not.toThrow(); + }); + + it('should accept valid options with log function', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: (level: string, message: string) => { + console.log(`[${level}] ${message}`); + }, + }); + }).not.toThrow(); + }); + + it('should throw error when schema is missing', () => { + expect(() => { + new RPCApiHandler({} as any); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is not an object', () => { + expect(() => { + new RPCApiHandler({ + schema: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is null', () => { + expect(() => { + new RPCApiHandler({ + schema: null as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when schema is undefined', () => { + expect(() => { + new RPCApiHandler({ + schema: undefined as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is invalid type', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log array contains invalid values', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: ['debug', 'invalid'] as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is a number', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: 123 as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when log is an object', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: {} as any, + }); + }).toThrow('Invalid options'); + }); + + it('should accept empty log array', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: [], + }); + }).not.toThrow(); + }); + + it('should accept single log level', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: ['error'], + }); + }).not.toThrow(); + }); + + it('should throw error with extra unknown options', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + unknownOption: 'value', + } as any); + }).toThrow('Invalid options'); // z.strictObject() rejects extra properties + }); + }); + + describe('strictObject validation - extra properties', () => { + it('RestApiHandler should reject extra unknown properties', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + extraProperty: 'should-fail', + } as any); + }).toThrow('Invalid options'); + }); + + it('RPCApiHandler should reject extra unknown properties', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + extraProperty: 'should-fail', + } as any); + }).toThrow('Invalid options'); + }); + + it('RestApiHandler should reject multiple extra unknown properties', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + extra1: 'value1', + extra2: 'value2', + } as any); + }).toThrow('Invalid options'); + }); + + it('RPCApiHandler should reject multiple extra unknown properties', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + extra1: 'value1', + extra2: 'value2', + } as any); + }).toThrow('Invalid options'); + }); + }); + + describe('Edge Cases and Type Safety', () => { + it('RestApiHandler should handle undefined optional fields gracefully', () => { + const handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: undefined, + pageSize: undefined, + idDivider: undefined, + }); + expect(handler).toBeDefined(); + }); + + it('RPCApiHandler should handle undefined optional fields gracefully', () => { + const handler = new RPCApiHandler({ + schema: client.$schema, + log: undefined, + }); + expect(handler).toBeDefined(); + }); + + it('RestApiHandler should expose schema property', () => { + const handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + expect(handler.schema).toBe(client.$schema); + }); + + it('RPCApiHandler should expose schema property', () => { + const handler = new RPCApiHandler({ + schema: client.$schema, + }); + expect(handler.schema).toBe(client.$schema); + }); + + it('RestApiHandler should expose log property', () => { + const logConfig = ['debug', 'error'] as const; + const handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + log: logConfig, + }); + expect(handler.log).toBe(logConfig); + }); + + it('RPCApiHandler should expose log property', () => { + const logConfig = ['debug', 'error'] as const; + const handler = new RPCApiHandler({ + schema: client.$schema, + log: logConfig, + }); + expect(handler.log).toBe(logConfig); + }); + + it('RestApiHandler should handle empty modelNameMapping', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: {}, + }); + }).not.toThrow(); + }); + + it('RestApiHandler should handle empty externalIdMapping', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: {}, + }); + }).not.toThrow(); + }); + }); + + describe('Real-world Scenarios', () => { + it('RestApiHandler with production-like configuration', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'https://api.example.com/v1', + log: (level, message) => { + if (level === 'error') { + console.error(message); + } + }, + pageSize: 100, + idDivider: '_', + modelNameMapping: { + User: 'users', + }, + }); + }).not.toThrow(); + }); + + it('RPCApiHandler with production-like configuration', () => { + expect(() => { + new RPCApiHandler({ + schema: client.$schema, + log: (level, message) => { + if (level === 'error') { + console.error(message); + } + }, + }); + }).not.toThrow(); + }); + + it('RestApiHandler with disabled pagination (Infinity pageSize)', () => { + // Note: According to the code, this would need to be set to Infinity + // after construction, not in options, as Zod validation requires positive number + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 999999, // Large number as workaround + }); + }).not.toThrow(); + }); + }); + + describe('Schema validation', () => { + it('RestApiHandler should validate schema structure', () => { + const validSchema = client.$schema; + expect(() => { + new RestApiHandler({ + schema: validSchema, + endpoint: 'http://localhost/api', + }); + }).not.toThrow(); + }); + + it('RPCApiHandler should validate schema structure', () => { + const validSchema = client.$schema; + expect(() => { + new RPCApiHandler({ + schema: validSchema, + }); + }).not.toThrow(); + }); + + it('RestApiHandler should handle empty schema object but will error when building type map', () => { + // Empty schema passes Zod validation (z.object()) but fails when building type map + expect(() => { + new RestApiHandler({ + schema: {} as any, + endpoint: 'http://localhost/api', + }); + }).toThrow(); // Throws when trying to build type map from empty schema + }); + }); +}); From 89770435f379dfbc750ccb76d9338906a72832b2 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:22:37 +0800 Subject: [PATCH 13/13] fix pr comments --- packages/server/src/api/rest/index.ts | 2 +- packages/server/test/api/options-validation.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index ef72185f4..b4e5dce25 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -291,7 +291,7 @@ export class RestApiHandler implements Api const schema = z.strictObject({ schema: z.object(), log: loggerSchema.optional(), - endpoint: z.string(), + endpoint: z.string().min(1), pageSize: z.number().positive().optional(), idDivider: z.string().min(1).optional(), urlSegmentCharset: z.string().min(1).optional(), diff --git a/packages/server/test/api/options-validation.test.ts b/packages/server/test/api/options-validation.test.ts index 63e81b4de..5861a13c1 100644 --- a/packages/server/test/api/options-validation.test.ts +++ b/packages/server/test/api/options-validation.test.ts @@ -72,7 +72,7 @@ describe('API Handler Options Validation', () => { }).toThrow('Invalid options'); }); - it('should accept empty string endpoint', () => { + it('should throw error when endpoint is empty string', () => { // Note: Zod z.string() validation allows empty strings // The endpoint validation doesn't enforce non-empty string expect(() => { @@ -80,7 +80,7 @@ describe('API Handler Options Validation', () => { schema: client.$schema, endpoint: '', }); - }).not.toThrow(); + }).toThrow('Invalid options'); }); it('should throw error when endpoint is not a string', () => {