diff --git a/packages/api/src/parsers/DataItemVariableResolver.ts b/packages/api/src/parsers/DataItemVariableResolver.ts index d719584e3..12beca1a0 100644 --- a/packages/api/src/parsers/DataItemVariableResolver.ts +++ b/packages/api/src/parsers/DataItemVariableResolver.ts @@ -16,7 +16,8 @@ import type { import { objectMap } from '../util.ts' import type { VariablesRegistry } from './variablesRegistry.ts' -export const variableStringParser = templateLiteral(['{{', string(), '}}']) +export const variableStringParser = () => + templateLiteral(['{{', string(), '}}']) export type VariableStringParser = ZodMiniTemplateLiteral<`{{${string}}}`> export function DataItemVariableResolverParser( @@ -24,7 +25,7 @@ export function DataItemVariableResolverParser( parser: $ZodType, ): DataItemVariableResolverParser { return pipe( - variableStringParser, + variableStringParser(), transform((input, ctx) => stringToVariableResolver(registry, parser, input, ctx) ), @@ -65,8 +66,10 @@ export function isVariableResolver(x: unknown): x is VariableResolver { } export function resolveVariables(variables: Record, x: unknown) { - return typeof x === 'object' && x !== null && !Array.isArray(x) - ? recursivelyResolveVariables(variables, x as Record) + return Array.isArray(x) + ? recursivelyResolveArrayVariables(variables, x) + : typeof x === 'object' && x !== null + ? recursivelyResolveObjectVariables(variables, x as Record) : resolveVariable(variables, x) } @@ -80,7 +83,14 @@ function resolveVariable(variables: Record, x: unknown) { return isVariableResolver(x) ? x(variables) : x } -function recursivelyResolveVariables( +function recursivelyResolveArrayVariables( + variables: Record, + x: unknown[], +): unknown[] { + return x.map((value) => resolveVariables(variables, value)) +} + +function recursivelyResolveObjectVariables( variables: Record, x: Record, ): Record { diff --git a/packages/api/src/parsers/attachVariableResolver.ts b/packages/api/src/parsers/attachVariableResolver.ts index 239790397..d25e8c438 100644 --- a/packages/api/src/parsers/attachVariableResolver.ts +++ b/packages/api/src/parsers/attachVariableResolver.ts @@ -15,7 +15,7 @@ import { type VariableStringParser, variableStringParser, } from './DataItemVariableResolver.ts' -import type { $ZodObject, $ZodType } from 'zod/v4/core' +import type { $ZodArray, $ZodObject, $ZodRecord, $ZodType } from 'zod/v4/core' import { any, type ZodObject } from 'zod' import { type ZodSwitch, zodSwitch } from './switch.ts' import type { VariablesRegistry } from './variablesRegistry.ts' @@ -31,7 +31,19 @@ export function attachVariableResolver< return objectVariableResolverParser( variablesRegistry, parser as unknown as (ZodObject | ZodMiniObject), - ) as any + ) as unknown as RecursiveVariableResolver + + case 'record': + return recordVariableResolverParser( + variablesRegistry, + parser as unknown as $ZodRecord, + ) as RecursiveVariableResolver + + case 'array': + return arrayVariableResolverParser( + variablesRegistry, + parser as unknown as $ZodArray, + ) as RecursiveVariableResolver default: return variableResolverParser( @@ -61,13 +73,47 @@ function variableResolverParser< ) : zodSwitch([ [ - variableStringParser, + variableStringParser(), DataItemVariableResolverParser(variablesRegistry, parser), ], [any(), parser], ])) as WithVariableResolver } +function arrayVariableResolverParser( + variablesRegistry: VariablesRegistry, + arrayParser: Parser, +): RecursiveVariableResolver { + const $arrayParser = new arrayParser._zod.constr({ + ...arrayParser._zod.def, + element: attachVariableResolver( + variablesRegistry, + arrayParser._zod.def.element, + ), + }) + return variableResolverParser( + variablesRegistry, + $arrayParser, + ) as RecursiveVariableResolver +} + +function recordVariableResolverParser( + variablesRegistry: VariablesRegistry, + recordParser: Parser, +): RecursiveVariableResolver { + const $recordParser = new recordParser._zod.constr({ + ...recordParser._zod.def, + valueType: attachVariableResolver( + variablesRegistry, + recordParser._zod.def.valueType, + ), + }) + return variableResolverParser( + variablesRegistry, + $recordParser, + ) as RecursiveVariableResolver +} + function objectVariableResolverParser< Parser extends ZodObject | ZodMiniObject, >( @@ -109,16 +155,28 @@ type WithVariableResolver< export type RecursiveVariableResolver< Parser extends $ZodType, -> = Parser extends $ZodObject ? WithVariableResolver< - $ZodObject< - { - [K in keyof Parser['_zod']['def']['shape']]: RecursiveVariableResolver< - Parser['_zod']['def']['shape'][K] - > - }, - ZodObjectConfig +> = Parser extends $ZodArray ? WithVariableResolver< + $ZodArray< + RecursiveVariableResolver > > + : Parser extends $ZodRecord ? WithVariableResolver< + $ZodRecord< + Parser['_zod']['def']['keyType'], + RecursiveVariableResolver + > + > + : Parser extends $ZodObject ? WithVariableResolver< + $ZodObject< + { + [K in keyof Parser['_zod']['def']['shape']]: + RecursiveVariableResolver< + Parser['_zod']['def']['shape'][K] + > + }, + ZodObjectConfig + > + > : WithVariableResolver type ZodObjectConfig = T extends $ZodObject diff --git a/packages/api/src/parsers/switch.ts b/packages/api/src/parsers/switch.ts index b68fc9074..df11b02d6 100644 --- a/packages/api/src/parsers/switch.ts +++ b/packages/api/src/parsers/switch.ts @@ -1,39 +1,85 @@ +import { safeParse, union, type ZodMiniUnion } from 'zod/mini' import { - custom, - registry, - safeParse, - superRefine, - union, - type ZodMiniCustom, - type ZodMiniUnion, -} from 'zod/mini' -import type { $ZodRegistry, $ZodType } from 'zod/v4/core' + $constructor, + type $ZodCustomDef, + type $ZodCustomInternals, + $ZodType, + type $ZodType as $ZodTypeType, + NEVER, +} from 'zod/v4/core' /** * A tuple array representing switch case mappings. * Each entry is a [condition, parser] pair where the condition determines which parser to use. */ -export type $ZodSwitchMap = [condition: $ZodType, parser: $ZodType][] +export type $ZodSwitchMap = [condition: $ZodTypeType, parser: $ZodTypeType][] /** - * A Zod custom schema type that evaluates conditions and applies the corresponding parser. - * The output and input types are derived from the parser schemas in the switch map. + * Definition for a ZodSwitch schema. + * Extends the base custom def with a switchMap and precomputed union. + */ +export interface ZodSwitchDef extends $ZodCustomDef { + switchMap: $ZodSwitchMap + union: ZodMiniUnion +} + +/** + * Internal state for a ZodSwitch schema. + */ +export interface ZodSwitchInternals + extends $ZodCustomInternals { + def: ZodSwitchDef +} + +/** + * A Zod schema that evaluates conditions and applies the corresponding parser. + * The union metadata is stored directly on the instance (no external registry). * * @template SwitchMap - The switch map defining condition-parser pairs */ -export type ZodSwitch = - ZodMiniCustom< +export interface ZodSwitch + extends $ZodType { + _zod: ZodSwitchInternals< SwitchMap[number][1]['_zod']['output'], SwitchMap[number][1]['_zod']['input'] > +} + +export const ZodSwitch: $constructor = $constructor( + 'ZodSwitch', + (inst, def) => { + $ZodType.init(inst, def) + inst._zod.parse = (payload) => { + const input = payload.value + payload.value = NEVER + for (const [condition, parser] of def.switchMap) { + const conditionResult = safeParse(condition, input) + if (conditionResult.success) { + const parseResult = safeParse(parser, conditionResult.data) + if (parseResult.success) { + payload.value = parseResult.data + } else { + for (const issue of parseResult.error.issues) { + payload.issues.push({ + ...issue, + input: input as any, + }) + } + } + break + } + } + return payload + } + }, +) /** - * Registry for switch schemas that stores metadata about the union of all possible parsers. - * Used internally by zodSwitch to register schema information. + * Check if a Zod schema is a ZodSwitch instance. */ -export const switchRegistry: $ZodRegistry<{ - union: ZodMiniUnion -}, ZodSwitch<$ZodSwitchMap>> = registry() +export function isZodSwitch(parser: $ZodTypeType): parser is ZodSwitch { + return parser instanceof ZodSwitch +} /** * Create a conditional Zod parser that evaluates different schemas based on conditions. @@ -57,30 +103,12 @@ export const switchRegistry: $ZodRegistry<{ export function zodSwitch( switchMap: SwitchMap, ): ZodSwitch { - return custom< - SwitchMap[number][1]['_zod']['output'], - SwitchMap[number][1]['_zod']['input'] - >().check( - superRefine((input, payload) => { - for (const [condition, parser] of switchMap) { - const conditionResult = safeParse(condition, input) - if (conditionResult.success) { - const parseResult = safeParse(parser, conditionResult.data) - if (parseResult.success) payload.value = parseResult.data - else { - for (const issue of parseResult.error.issues) { - payload.addIssue({ - ...issue, - input: input as any, - }) - } - } - break - } - } - }), - ) - .register(switchRegistry, { - union: union(switchMap.map(([, type]) => type)) as any, - }) + return new ZodSwitch({ + type: 'custom', + check: 'custom', + fn: () => true, + abort: true, + switchMap, + union: union(switchMap.map(([, type]) => type)) as ZodMiniUnion, + }) as ZodSwitch } diff --git a/packages/api/test/Data.test.ts b/packages/api/test/Data.test.ts index a93e0d109..f16e78374 100644 --- a/packages/api/test/Data.test.ts +++ b/packages/api/test/Data.test.ts @@ -1,4 +1,8 @@ -import { assertRejects, assertStrictEquals } from 'jsr:@std/assert' +import { + assertEquals, + assertRejects, + assertStrictEquals, +} from 'jsr:@std/assert' import { assertSnapshot } from 'jsr:@std/testing/snapshot' import { setTimeout } from 'node:timers/promises' import z, { type ZodError } from 'zod' @@ -589,9 +593,9 @@ Deno.test('variables', async (t) => { ], }) - assertSnapshot(t, await data.getPayload('foo', { channel: 'bar' })) + await assertSnapshot(t, await data.getPayload('foo', { channel: 'bar' })) - assertSnapshot(t, await data.getPayload('foo')) + await assertSnapshot(t, await data.getPayload('foo')) }) Deno.test('variables using fallthrough targeting', async (t) => { @@ -646,7 +650,60 @@ Deno.test('variables using fallthrough targeting', async (t) => { ], }) - assertSnapshot(t, await data.getPayload('foo', { channel: 'bar' })) + await assertSnapshot(t, await data.getPayload('foo', { channel: 'bar' })) +}) + +Deno.test('variables in records', async () => { + const data = await Data.create() + .usePayload({ + foo: z.record(z.string(), z.array(z.number())), + }) + .addRules('foo', { + variables: { + a: [{ payload: [1, 2, 3] }], + }, + rules: [{ payload: { a: '{{a}}' } }], + }) + + const payload = await data.getPayload('foo') + assertEquals(payload, { a: [1, 2, 3] }) +}) + +Deno.test('variables in arrays', async (t) => { + const data = await Data.create() + .usePayload({ + foo: z.array(z.number()), + bar: z.array(z.strictObject({ + b: z.number(), + c: z.string(), + })), + }).addRules('foo', { + variables: { + a: [{ payload: 1 }], + }, + rules: [{ payload: ['{{a}}'] }], + }).addRules('bar', { + variables: { + b: [{ payload: 2 }], + c: [{ payload: '3' }], + }, + rules: [{ payload: [{ b: '{{b}}', c: '{{c}}' }] }], + }) + + await assertSnapshot( + t, + data.data, + ) + + assertEquals( + await data.getPayload('foo'), + [1], + ) + + assertEquals( + await data.getPayload('bar'), + [{ b: 2, c: '3' }], + ) }) Deno.test('errors when using variables with incorrect types', async (t) => { @@ -661,7 +718,7 @@ Deno.test('errors when using variables with incorrect types', async (t) => { }), }) - assertSnapshot( + await assertSnapshot( t, await data.addRules('foo', { variables: { diff --git a/packages/api/test/__snapshots__/Data.test.ts.snap b/packages/api/test/__snapshots__/Data.test.ts.snap index b2f4c9533..20cd38c2f 100644 --- a/packages/api/test/__snapshots__/Data.test.ts.snap +++ b/packages/api/test/__snapshots__/Data.test.ts.snap @@ -377,6 +377,57 @@ snapshot[`variables using fallthrough targeting 1`] = ` } `; +snapshot[`variables in arrays 1`] = ` +{ + bar: { + rules: [ + { + payload: [ + { + b: [Function: resolver] { + "\$\$resolver\$\$": true, + }, + c: [Function: resolver] { + "\$\$resolver\$\$": true, + }, + }, + ], + }, + ], + variables: { + b: [ + { + payload: 2, + }, + ], + c: [ + { + payload: "3", + }, + ], + }, + }, + foo: { + rules: [ + { + payload: [ + [Function: resolver] { + "\$\$resolver\$\$": true, + }, + ], + }, + ], + variables: { + a: [ + { + payload: 1, + }, + ], + }, + }, +} +`; + snapshot[`errors when using variables with incorrect types 1`] = ` [ { diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index 33c36394f..40a0dc3ab 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -3,11 +3,10 @@ import { DataItemParser, DataItemsParser, type DT, - switchRegistry, - type ZodSwitch, + isZodSwitch, } from '@targetd/api' import { extend, optional, string, toJSONSchema } from 'zod/mini' -import type { $ZodType, JSONSchema } from 'zod/v4/core' +import type { JSONSchema } from 'zod/v4/core' /** * Generate JSON Schema for all data items in a Data instance. @@ -76,44 +75,26 @@ export function dataJSONSchema( ) } -type _ToJSONSchemaParams = NonNullable[1]> +export type ToJSONSchemaParams = NonNullable[1]> -export type ToJSONSchemaParams = _ToJSONSchemaParams & { - override?: ( - ...args: Parameters['override']> - ) => void | boolean -} - -function toJSONSchemaParams(params?: ToJSONSchemaParams): _ToJSONSchemaParams { +function toJSONSchemaParams(params?: ToJSONSchemaParams): ToJSONSchemaParams { return { io: 'input', reused: 'ref', unrepresentable: 'any', override(ctx) { - if (params?.override?.(ctx)) return if (isZodSwitch(ctx.zodSchema)) { - const union = switchRegistry.get(ctx.zodSchema) - ?.union - if (union) { - const schema = toJSONSchema( - union as any, - toJSONSchemaParams(params), - ) - // Mutate the jsonSchema in place - ctx.jsonSchema is a reference - // to the actual schema object, so we must modify it directly - for (const key in ctx.jsonSchema) { - delete (ctx.jsonSchema as Record)[key] - } - Object.assign(ctx.jsonSchema, schema) - delete (ctx.jsonSchema as Record).$schema - } + Object.assign( + ctx.jsonSchema, + toJSONSchema(ctx.zodSchema._zod.def.union, { + ...toJSONSchemaParams(), + reused: 'inline', + }), + ) + delete ctx.jsonSchema.$schema } + params?.override?.(ctx) }, ...(params && omit(params, ['override'])), } } - -function isZodSwitch(parser: $ZodType): parser is ZodSwitch { - return parser._zod.def.type === 'custom' && - switchRegistry.has(parser as ZodSwitch) -} diff --git a/packages/json-schema/test/__snapshots__/index.test.ts.snap b/packages/json-schema/test/__snapshots__/index.test.ts.snap index 78fbe6c05..a1d5ad45c 100644 --- a/packages/json-schema/test/__snapshots__/index.test.ts.snap +++ b/packages/json-schema/test/__snapshots__/index.test.ts.snap @@ -15,6 +15,128 @@ snapshot[`json schema for simple data object 1`] = ` }, type: "object", }, + __schema10: { + additionalProperties: false, + properties: { + browser: { + "\$ref": "#/\$defs/__schema2", + }, + weather: { + "\$ref": "#/\$defs/__schema1", + }, + }, + type: "object", + }, + __schema11: { + type: "string", + }, + __schema12: { + items: { + additionalProperties: false, + properties: { + payload: { + anyOf: [ + { + pattern: "^\\\\{\\\\{[\\\\s\\\\S]{0,}\\\\}\\\\}\$", + type: "string", + }, + {}, + ], + }, + targeting: { + anyOf: [ + { + "\$ref": "#/\$defs/__schema13", + }, + { + items: { + "\$ref": "#/\$defs/__schema13", + }, + type: "array", + }, + ], + }, + }, + required: [ + "payload", + ], + type: "object", + }, + type: "array", + }, + __schema13: { + additionalProperties: false, + properties: { + browser: { + "\$ref": "#/\$defs/__schema2", + }, + weather: { + "\$ref": "#/\$defs/__schema1", + }, + }, + type: "object", + }, + __schema14: { + additionalProperties: false, + properties: { + browser: { + "\$ref": "#/\$defs/__schema2", + }, + weather: { + "\$ref": "#/\$defs/__schema1", + }, + }, + type: "object", + }, + __schema15: { + type: "string", + }, + __schema16: { + items: { + additionalProperties: false, + properties: { + payload: { + anyOf: [ + { + pattern: "^\\\\{\\\\{[\\\\s\\\\S]{0,}\\\\}\\\\}\$", + type: "string", + }, + {}, + ], + }, + targeting: { + anyOf: [ + { + "\$ref": "#/\$defs/__schema17", + }, + { + items: { + "\$ref": "#/\$defs/__schema17", + }, + type: "array", + }, + ], + }, + }, + required: [ + "payload", + ], + type: "object", + }, + type: "array", + }, + __schema17: { + additionalProperties: false, + properties: { + browser: { + "\$ref": "#/\$defs/__schema2", + }, + weather: { + "\$ref": "#/\$defs/__schema1", + }, + }, + type: "object", + }, __schema1: { items: { type: "string", @@ -144,6 +266,71 @@ snapshot[`json schema for simple data object 1`] = ` "\$schema": { type: "string", }, + array: { + additionalProperties: false, + properties: { + rules: { + items: { + additionalProperties: false, + properties: { + payload: { + anyOf: [ + { + pattern: "^\\\\{\\\\{[\\\\s\\\\S]{0,}\\\\}\\\\}\$", + type: "string", + }, + { + items: { + anyOf: [ + { + pattern: "^\\\\{\\\\{[\\\\s\\\\S]{0,}\\\\}\\\\}\$", + type: "string", + }, + { + type: "number", + }, + ], + }, + type: "array", + }, + ], + }, + targeting: { + anyOf: [ + { + "\$ref": "#/\$defs/__schema14", + }, + { + items: { + "\$ref": "#/\$defs/__schema14", + }, + type: "array", + }, + ], + }, + }, + required: [ + "payload", + ], + type: "object", + }, + type: "array", + }, + variables: { + additionalProperties: { + "\$ref": "#/\$defs/__schema16", + }, + propertyNames: { + "\$ref": "#/\$defs/__schema15", + }, + type: "object", + }, + }, + required: [ + "rules", + ], + type: "object", + }, bar: { additionalProperties: false, properties: { @@ -276,6 +463,74 @@ snapshot[`json schema for simple data object 1`] = ` ], type: "object", }, + record: { + additionalProperties: false, + properties: { + rules: { + items: { + additionalProperties: false, + properties: { + payload: { + anyOf: [ + { + pattern: "^\\\\{\\\\{[\\\\s\\\\S]{0,}\\\\}\\\\}\$", + type: "string", + }, + { + additionalProperties: { + anyOf: [ + { + pattern: "^\\\\{\\\\{[\\\\s\\\\S]{0,}\\\\}\\\\}\$", + type: "string", + }, + { + type: "number", + }, + ], + }, + propertyNames: { + type: "string", + }, + type: "object", + }, + ], + }, + targeting: { + anyOf: [ + { + "\$ref": "#/\$defs/__schema10", + }, + { + items: { + "\$ref": "#/\$defs/__schema10", + }, + type: "array", + }, + ], + }, + }, + required: [ + "payload", + ], + type: "object", + }, + type: "array", + }, + variables: { + additionalProperties: { + "\$ref": "#/\$defs/__schema12", + }, + propertyNames: { + "\$ref": "#/\$defs/__schema11", + }, + type: "object", + }, + }, + required: [ + "rules", + ], + type: "object", + }, }, type: "object", } diff --git a/packages/json-schema/test/fixtures/data.ts b/packages/json-schema/test/fixtures/data.ts index c2c40c491..b4bd7946c 100644 --- a/packages/json-schema/test/fixtures/data.ts +++ b/packages/json-schema/test/fixtures/data.ts @@ -1,5 +1,5 @@ import { Data, targetIncludes } from '@targetd/api' -import z from 'zod' +import { z } from 'zod/mini' export const data = await Data.create() .usePayload( @@ -9,6 +9,8 @@ export const data = await Data.create() a: z.number(), b: z.array(z.string()), }), + record: z.record(z.string(), z.number()), + array: z.array(z.number()), }, ) .useTargeting({ @@ -57,3 +59,10 @@ export const data = await Data.create() }, }, ]) + .addRules('record', [ + { + payload: { + a: 123, + }, + }, + ])