diff --git a/packages/js-lib/src/zod/zod.shared.schemas.test.ts b/packages/js-lib/src/zod/zod.shared.schemas.test.ts index 8b71b0fb2..5f85db6c0 100644 --- a/packages/js-lib/src/zod/zod.shared.schemas.test.ts +++ b/packages/js-lib/src/zod/zod.shared.schemas.test.ts @@ -141,6 +141,10 @@ describe('z.isoDate', () => { '2001-W01', // valid ISO 8601 YYYY-Www '2001W01', // valid ISO 8601 YYYYWww '2001-01-1', // invalid + '2001-13-01', + '2001-21-01', + '2001-08-32', + '2001-08-32', ] test.each(invalidCases)('should not accept %s format', date => { const schema = z.isoDate() @@ -152,4 +156,116 @@ describe('z.isoDate', () => { expect(ajvResult[0]).not.toBeNull() expect(ajvResult[1]).toBe(date) }) + + test('should accept valid dates when `before` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ before: date }) + + expect(schema.safeParse('2000-12-30').success).toBe(true) + expect(schema.safeParse('2000-12-31').success).toBe(true) + expect(schema.safeParse('2001-01-01').success).toBe(false) + expect(schema.safeParse('2001-01-02').success).toBe(false) + + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).not.toBeNull() + }) + + test('should accept valid dates when `sameOrBefore` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ sameOrBefore: date }) + + expect(schema.safeParse('2000-12-30').success).toBe(true) + expect(schema.safeParse('2000-12-31').success).toBe(true) + expect(schema.safeParse('2001-01-01').success).toBe(true) + expect(schema.safeParse('2001-01-02').success).toBe(false) + + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).not.toBeNull() + }) + + test('should accept valid dates when `after` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ after: date }) + + expect(schema.safeParse('2000-12-30').success).toBe(false) + expect(schema.safeParse('2000-12-31').success).toBe(false) + expect(schema.safeParse('2001-01-01').success).toBe(false) + expect(schema.safeParse('2001-01-02').success).toBe(true) + + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + }) + + test('should accept valid dates when `sameOrAfter` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ sameOrAfter: date }) + + expect(schema.safeParse('2000-12-30').success).toBe(false) + expect(schema.safeParse('2000-12-31').success).toBe(false) + expect(schema.safeParse('2001-01-01').success).toBe(true) + expect(schema.safeParse('2001-01-02').success).toBe(true) + + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + }) + + test('should accept valid dates when `between` param is defined with `[]`', () => { + const date1 = '2001-01-01' as IsoDate + const date2 = '2001-01-03' as IsoDate + const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[]' } }) + + expect(schema.safeParse('2000-12-30').success).toBe(false) + expect(schema.safeParse('2000-12-31').success).toBe(false) + expect(schema.safeParse('2001-01-01').success).toBe(true) + expect(schema.safeParse('2001-01-02').success).toBe(true) + expect(schema.safeParse('2001-01-03').success).toBe(true) + expect(schema.safeParse('2001-01-04').success).toBe(false) + + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-03')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-04')[0]).not.toBeNull() + }) + + test('should accept valid dates when `between` param is defined with `[)`', () => { + const date1 = '2001-01-01' as IsoDate + const date2 = '2001-01-03' as IsoDate + const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[)' } }) + + expect(schema.safeParse('2000-12-30').success).toBe(false) + expect(schema.safeParse('2000-12-31').success).toBe(false) + expect(schema.safeParse('2001-01-01').success).toBe(true) + expect(schema.safeParse('2001-01-02').success).toBe(true) + expect(schema.safeParse('2001-01-03').success).toBe(false) + expect(schema.safeParse('2001-01-04').success).toBe(false) + + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-03')[0]).not.toBeNull() + expect(ajvSchema.getValidationResult('2001-01-04')[0]).not.toBeNull() + }) }) diff --git a/packages/js-lib/src/zod/zod.shared.schemas.ts b/packages/js-lib/src/zod/zod.shared.schemas.ts index 51c9551f8..85b677188 100644 --- a/packages/js-lib/src/zod/zod.shared.schemas.ts +++ b/packages/js-lib/src/zod/zod.shared.schemas.ts @@ -1,6 +1,15 @@ import type { ZodString } from 'zod' import { z } from 'zod' -import type { IANATimezone, IsoDate, UnixTimestamp, UnixTimestampMillis } from '../types.js' +import { localDate } from '../datetime/localDate.js' +import { _assert } from '../error/assert.js' +import { + _typeCast, + type IANATimezone, + type Inclusiveness, + type IsoDate, + type UnixTimestamp, + type UnixTimestampMillis, +} from '../types.js' type ZodBranded = T & Record<'_zod', Record<'output', B>> export type ZodBrandedString = ZodBranded @@ -54,11 +63,59 @@ function semVer(): z.ZodString { .describe('SemVer') } -function isoDate(): ZodBrandedString { - return z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, { error: 'Must be a YYYY-MM-DD string' }) - .describe('IsoDate') as ZodBrandedString +export interface CustomZodIsoDateParams { + before?: IsoDate + sameOrBefore?: IsoDate + after?: IsoDate + sameOrAfter?: IsoDate + between?: { min: IsoDate; max: IsoDate; incl: Inclusiveness } +} + +export interface JsonSchemaDescriptionParams { + schema: 'isoDate' + params: CustomZodIsoDateParams +} + +function isoDate(params: CustomZodIsoDateParams = {}): ZodBrandedString { + const { before, sameOrBefore, after, sameOrAfter, between } = params + + _assert(Object.keys(params).length <= 1, 'Only one condition is allowed in `isoDate()`!') + + let error = 'Should be be a YYYY-MM-DD string' + if (after) error = `Should be after ${after}` + if (sameOrAfter) error = `Should be on or after ${sameOrAfter}` + if (before) error = `Should be before ${before}` + if (sameOrBefore) error = `Should be on or before ${sameOrBefore}` + if (between) { + const { min, max, incl } = between + error = `Should be between ${min} and ${max} (incl: ${incl})` + } + + let schema = z.string().refine( + dateString => { + if (!localDate.isValidString(dateString)) return false + _typeCast(dateString) + + const ld = localDate.fromString(dateString) + + if (before) return ld.isBefore(before) + if (sameOrBefore) return ld.isSameOrBefore(sameOrBefore) + if (after) return ld.isAfter(after) + if (sameOrAfter) return ld.isSameOrAfter(sameOrAfter) + if (between) return ld.isBetween(between.min, between.max, between.incl) + + return true + }, + { error }, + ) + + // Here we hide the instructions in the description that Ajv will understand + // For some reason, if I add the `.describe()` earlier to support early-return when no conditions are specified, + // then the description is lost. It seems it must be the last call in the call chain. + const description = { schema: 'isoDate', params } satisfies JsonSchemaDescriptionParams + schema = schema.describe(JSON.stringify(description)) + + return schema as ZodBrandedString } function email(): z.ZodEmail { diff --git a/packages/nodejs-lib/src/validation/ajv/ajvSchema.test.ts b/packages/nodejs-lib/src/validation/ajv/ajvSchema.test.ts index f6ae8228c..a691b9806 100644 --- a/packages/nodejs-lib/src/validation/ajv/ajvSchema.test.ts +++ b/packages/nodejs-lib/src/validation/ajv/ajvSchema.test.ts @@ -1,4 +1,4 @@ -import { _typeCast } from '@naturalcycles/js-lib/types' +import { _typeCast, type IsoDate } from '@naturalcycles/js-lib/types' import { z } from '@naturalcycles/js-lib/zod' import { describe, expect, test } from 'vitest' import { AjvSchema, HIDDEN_AJV_SCHEMA, type ZodTypeWithAjvSchema } from './ajvSchema.js' @@ -35,3 +35,154 @@ describe('createFromZod', () => { expect(ajvSchemaAgain).toBe(ajvSchema) }) }) + +describe('isoDate keyword', () => { + test('should accept 2001-01-01 ISO date format', () => { + const date = '2001-01-01' + const schema = z.isoDate() + + const result = schema.parse(date) + expect(result).toBe(date) + + const ajvResult = AjvSchema.createFromZod(schema).getValidationResult(date) + expect(ajvResult[0]).toBeNull() + expect(ajvResult[1]).toBe(date) + }) + + const invalidCases = [ + '20010101', // valid ISO 8601 YYYYMMDD + '2001-01', // valid ISO 8601 YYYY-MM + '2001-W01-1', // valid ISO 8601 YYYY-Www-D + '2001W011', // valid ISO 8601 YYYYWwwD + '2001-W01', // valid ISO 8601 YYYY-Www + '2001W01', // valid ISO 8601 YYYYWww + '2001-01-1', // invalid + ] + test.each(invalidCases)('should not accept %s format', date => { + const schema = z.isoDate() + + const result = schema.safeParse(date) + expect(result.success).toBe(false) + + const ajvResult = AjvSchema.createFromZod(schema).getValidationResult(date) + expect(ajvResult[0]).not.toBeNull() + expect(ajvResult[1]).toBe(date) + }) + + test('should accept valid dates when `before` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ before: date }) + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be before 2001-01-01 + Input: 2001-01-01] + `) + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be before 2001-01-01 + Input: 2001-01-02] + `) + }) + + test('should accept valid dates when `sameOrBefore` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ sameOrBefore: date }) + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be on or before 2001-01-01 + Input: 2001-01-02] + `) + }) + + test('should accept valid dates when `after` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ after: date }) + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be after 2001-01-01 + Input: 2000-12-30] + `) + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be after 2001-01-01 + Input: 2000-12-31] + `) + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be after 2001-01-01 + Input: 2001-01-01] + `) + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + }) + + test('should accept valid dates when `sameOrAfter` param is defined', () => { + const date = '2001-01-01' as IsoDate + const schema = z.isoDate({ sameOrAfter: date }) + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be on or after 2001-01-01 + Input: 2000-12-30] + `) + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be on or after 2001-01-01 + Input: 2000-12-31] + `) + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + }) + + test('should accept valid dates when `between` param is defined with `[]`', () => { + const date1 = '2001-01-01' as IsoDate + const date2 = '2001-01-03' as IsoDate + const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[]' } }) + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: []) + Input: 2000-12-30] + `) + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: []) + Input: 2000-12-31] + `) + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-03')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-04')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: []) + Input: 2001-01-04] + `) + }) + + test('should accept valid dates when `between` param is defined with `[)`', () => { + const date1 = '2001-01-01' as IsoDate + const date2 = '2001-01-03' as IsoDate + const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[)' } }) + const ajvSchema = AjvSchema.createFromZod(schema) + + expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [)) + Input: 2000-12-30] + `) + expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [)) + Input: 2000-12-31] + `) + expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull() + expect(ajvSchema.getValidationResult('2001-01-03')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [)) + Input: 2001-01-03] + `) + expect(ajvSchema.getValidationResult('2001-01-04')[0]).toMatchInlineSnapshot(` + [AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [)) + Input: 2001-01-04] + `) + }) +}) diff --git a/packages/nodejs-lib/src/validation/ajv/ajvSchema.ts b/packages/nodejs-lib/src/validation/ajv/ajvSchema.ts index 1eea3fa61..591532ffd 100644 --- a/packages/nodejs-lib/src/validation/ajv/ajvSchema.ts +++ b/packages/nodejs-lib/src/validation/ajv/ajvSchema.ts @@ -125,6 +125,7 @@ export class AjvSchema { } const jsonSchema = z.toJSONSchema(zodSchema, { target: 'draft-7' }) + handleIsoDateSchemas(jsonSchema) const ajvSchema = new AjvSchema(jsonSchema as JsonSchema, cfg) AjvSchema.cacheAjvSchemaInZodSchema(zodSchema, ajvSchema) @@ -233,3 +234,44 @@ export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA') export interface ZodTypeWithAjvSchema extends ZodType { [HIDDEN_AJV_SCHEMA]: AjvSchema } + +/** + * This function iterates over the schema and updates every property with the schema `isoDate`. + * + * This is because our custom `isoDate` schema comes with logic + * that does not survive the JSONSchema translation between Ajv and Zod. + * We hide the parameters of the logic in the `description` field of the given field, + * which Ajv will pick up and execute. + */ +function handleIsoDateSchemas(schema: any): any { + if (schema && typeof schema === 'object') { + if (typeof schema.description === 'string') { + try { + const desc = JSON.parse(schema.description) + if (desc.schema === 'isoDate') { + schema.isoDate = desc.params // with this we instruct Ajv to execute the `isoDate` keyword logic + delete schema.description + } + } catch { + /* not our description */ + } + } + + // recurse into nested structures + if (schema.properties) { + Object.values(schema.properties).forEach(handleIsoDateSchemas) + } + if (schema.items) { + if (Array.isArray(schema.items)) { + schema.items.forEach(handleIsoDateSchemas) + } else { + handleIsoDateSchemas(schema.items) + } + } + if (schema.anyOf) schema.anyOf.forEach(handleIsoDateSchemas) + if (schema.allOf) schema.allOf.forEach(handleIsoDateSchemas) + if (schema.oneOf) schema.oneOf.forEach(handleIsoDateSchemas) + } + + return schema +} diff --git a/packages/nodejs-lib/src/validation/ajv/ajvSnapshot.test.ts b/packages/nodejs-lib/src/validation/ajv/ajvSnapshot.test.ts index 3f2292961..80f90de9a 100644 --- a/packages/nodejs-lib/src/validation/ajv/ajvSnapshot.test.ts +++ b/packages/nodejs-lib/src/validation/ajv/ajvSnapshot.test.ts @@ -23,16 +23,16 @@ test('snapshot ajv schema', async () => { const code = await prettify(rawCode) expect(code).toMatchInlineSnapshot(` "'use strict' - export const validate = validate13 - export default validate13 - const schema14 = { + export const validate = validate14 + export default validate14 + const schema15 = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', properties: { s: { type: 'string' } }, required: ['s'], additionalProperties: false, } - function validate13( + function validate14( data, { instancePath = '', parentData, parentDataProperty, rootData = data } = {}, ) { @@ -91,7 +91,7 @@ test('snapshot ajv schema', async () => { } errors++ } - validate13.errors = vErrors + validate14.errors = vErrors return errors === 0 } " diff --git a/packages/nodejs-lib/src/validation/ajv/getAjv.ts b/packages/nodejs-lib/src/validation/ajv/getAjv.ts index d78692aaf..fffcbf786 100644 --- a/packages/nodejs-lib/src/validation/ajv/getAjv.ts +++ b/packages/nodejs-lib/src/validation/ajv/getAjv.ts @@ -1,6 +1,9 @@ import { _lazyValue } from '@naturalcycles/js-lib' -import type { Options } from 'ajv' -import { Ajv } from 'ajv' +import { localDate as localDateImport } from '@naturalcycles/js-lib/datetime' +import { _typeCast } from '@naturalcycles/js-lib/types' +import type { CustomZodIsoDateParams } from '@naturalcycles/js-lib/zod' +import type { KeywordCxt, Options } from 'ajv' +import { _, Ajv, str } from 'ajv' import ajvFormats from 'ajv-formats' import ajvKeywords from 'ajv-keywords' @@ -66,6 +69,8 @@ export function createAjv(opt?: Options): Ajv { 'instanceof', ]) + addIsoDateKeyword(ajv) + // Adds $merge, $patch keywords // https://github.com/ajv-validator/ajv-merge-patch // Kirill: temporarily disabled, as it creates a noise of CVE warnings @@ -133,3 +138,92 @@ function addCustomAjvFormats(ajv: Ajv): Ajv { }) ) } + +function addIsoDateKeyword(ajv: Ajv): void { + ajv.addKeyword({ + keyword: 'isoDate', + type: 'string', + schemaType: 'object', + + metaSchema: { + type: 'object', + additionalProperties: false, + minProperties: 0, + maxProperties: 1, + properties: { + before: { type: 'string' }, + sameOrBefore: { type: 'string' }, + after: { type: 'string' }, + sameOrAfter: { type: 'string' }, + between: { + type: 'object', + required: ['min', 'max', 'incl'], + additionalProperties: false, + properties: { + min: { type: 'string' }, + max: { type: 'string' }, + incl: { enum: ['[]', '[)'] }, + }, + }, + }, + }, + + error: { + message: ({ schema }) => { + if (schema.after) return str`should be after ${schema.after}` + if (schema.sameOrAfter) return str`should be on or after ${schema.sameOrAfter}` + if (schema.before) return str`should be before ${schema.before}` + if (schema.sameOrBefore) return str`should be on or before ${schema.sameOrBefore}` + if (schema.between) { + const { min, max, incl } = schema.between + return str`should be between ${min} and ${max} (incl: ${incl})` + } + return str`should be a YYYY-MM-DD string` + }, + }, + + code(cxt: KeywordCxt) { + const { gen, data, schema } = cxt + _typeCast(schema) + + // Put the helper in Ajv's external "keyword" scope. The `key` isolates the entry. + const localDate = gen.scopeValue('keyword', { + key: str`nc:localDate`, + ref: localDateImport, // use the already-imported value when not generating standalone + code: _`require("@naturalcycles/js-lib/datetime").localDate`, // used for standalone code + }) + + gen.if(_`!${localDate}.isValidString(${data})`, () => { + cxt.fail(_`true`) + }) + + const d = gen.const('d', _`${localDate}.fromString(${data})`) + + if (schema.after) { + cxt.fail(_`!${d}.isAfter(${schema.after})`) + return + } + + if (schema.sameOrAfter) { + cxt.fail(_`!${d}.isSameOrAfter(${schema.sameOrAfter})`) + return + } + + if (schema.before) { + cxt.fail(_`!${d}.isBefore(${schema.before})`) + return + } + + if (schema.sameOrBefore) { + cxt.fail(_`!${d}.isSameOrBefore(${schema.sameOrBefore})`) + return + } + + if (schema.between) { + const { min, max, incl } = schema.between + cxt.fail(_`!${d}.isBetween(${min}, ${max}, ${incl})`) + return + } + }, + }) +}