From bf5346051efd6619fbe37303cd386591b7071589 Mon Sep 17 00:00:00 2001 From: John Wright Date: Mon, 20 Apr 2026 11:55:37 +0100 Subject: [PATCH 1/4] refactor: specific type assigners --- deno.json | 3 +- deno.lock | 10 ++++-- packages/api/src/ConfigurableData.ts | 16 ++------- packages/api/src/Data.ts | 50 +++++++++------------------- packages/api/src/PromisedData.ts | 15 ++------- packages/api/src/index.ts | 1 - packages/api/src/types/Data.ts | 45 +++++++++++++++++++++++++ packages/api/src/util.ts | 12 ------- 8 files changed, 76 insertions(+), 76 deletions(-) diff --git a/deno.json b/deno.json index f15a910d0..acec7e856 100644 --- a/deno.json +++ b/deno.json @@ -41,6 +41,7 @@ "imports": { "@commitlint/cli": "npm:@commitlint/cli@^20.5.0", "@commitlint/config-conventional": "npm:@commitlint/config-conventional@^20.5.0", - "lint-staged": "npm:lint-staged@^16.4.0" + "lint-staged": "npm:lint-staged@^16.4.0", + "utility-types": "npm:utility-types@^3.11.0" } } diff --git a/deno.lock b/deno.lock index 81384c99d..422b6d019 100644 --- a/deno.lock +++ b/deno.lock @@ -32,6 +32,7 @@ "npm:release-please@*": "17.3.0", "npm:supertest@*": "7.2.2", "npm:ts-toolbelt@^9.6.0": "9.6.0", + "npm:utility-types@^3.11.0": "3.11.0", "npm:yaml@^2.8.2": "2.8.2", "npm:yargs@18": "18.0.0", "npm:zod@^4.3.6": "4.3.6" @@ -496,7 +497,8 @@ ] }, "@xmldom/xmldom@0.8.11": { - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==" + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "deprecated": true }, "accepts@2.0.0": { "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", @@ -1991,6 +1993,9 @@ "unpipe@1.0.0": { "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, + "utility-types@3.11.0": { + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==" + }, "validate-npm-package-license@3.0.4": { "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dependencies": [ @@ -2088,7 +2093,8 @@ "dependencies": [ "npm:@commitlint/cli@^20.5.0", "npm:@commitlint/config-conventional@^20.5.0", - "npm:lint-staged@^16.4.0" + "npm:lint-staged@^16.4.0", + "npm:utility-types@^3.11.0" ], "members": { "packages/api": { diff --git a/packages/api/src/ConfigurableData.ts b/packages/api/src/ConfigurableData.ts index f424bd1eb..13259239f 100644 --- a/packages/api/src/ConfigurableData.ts +++ b/packages/api/src/ConfigurableData.ts @@ -1,9 +1,7 @@ import type { $ZodShape } from 'zod/v4/core' import type * as DT from './types/Data.ts' import type * as FTTT from './types/FallThroughTargeting.ts' -import type * as QT from './types/Query.ts' import type * as TT from './types/Targeting.ts' -import type { Merge } from './util.ts' /** * Interface for configuring a {@link Data} instance with payload schemas, @@ -32,7 +30,7 @@ export interface ConfigurableData<$ extends DT.Meta> { parsers: Parsers, ): Promise< ConfigurableData< - Merge<$, { PayloadParsers: Merge<$['PayloadParsers'], Parsers> }> + DT.AssignPayloadParsers<$, Parsers> > > @@ -44,10 +42,7 @@ export interface ConfigurableData<$ extends DT.Meta> { */ useTargeting(targeting: TDs): Promise< ConfigurableData< - Merge<$, { - TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord> - QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord> - }> + DT.AssignTargetingDescriptorRecord<$, TDs> > > @@ -62,12 +57,7 @@ export interface ConfigurableData<$ extends DT.Meta> { targeting: TDs, ): Promise< ConfigurableData< - Merge<$, { - FallThroughTargetingParsers: Merge< - $['FallThroughTargetingParsers'], - FTTT.ParsersRecord - > - }> + DT.AssignFallThroughTargetingParsers<$, FTTT.ParsersRecord> > > } diff --git a/packages/api/src/Data.ts b/packages/api/src/Data.ts index 31d250e72..9c4ef2141 100644 --- a/packages/api/src/Data.ts +++ b/packages/api/src/Data.ts @@ -1,6 +1,5 @@ import type TargetingPredicates from './parsers/TargetingPredicates.ts' import { - type Merge, objectEntries, objectEveryAsync, objectFitler, @@ -8,6 +7,7 @@ import { objectMap, objectSize, } from './util.ts' +import type { Assign } from 'utility-types' import { type DataItemsOut, DataItemsParser } from './parsers/DataItems.ts' import type { DataItemIn, DataItemOut } from './parsers/DataItem.ts' import type { DataItemRule } from './parsers/DataItemRule.ts' @@ -197,9 +197,9 @@ export default class Data<$ extends DT.Meta> * ``` */ async usePayload(parsers: Parsers): Promise< - Data }>> + Data> > { - type $$ = Merge<$, { PayloadParsers: Merge<$['PayloadParsers'], Parsers> }> + type $$ = DT.AssignPayloadParsers<$, Parsers> const payloadParsers = this.#mergePayloadParsers(parsers) @@ -221,11 +221,11 @@ export default class Data<$ extends DT.Meta> #mergePayloadParsers( parsers: Parsers, - ): Merge<$['PayloadParsers'], Parsers> { + ): Assign<$['PayloadParsers'], Parsers> { return { ...this.#payloadParsers, ...parsers, - } as Merge<$['PayloadParsers'], Parsers> + } as Assign<$['PayloadParsers'], Parsers> } /** @@ -429,17 +429,9 @@ export default class Data<$ extends DT.Meta> * ``` */ async useTargeting(targeting: TDs): Promise< - Data< - Merge<$, { - TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord> - QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord> - }> - > + Data> > { - type $$ = Merge<$, { - TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord> - QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord> - }> + type $$ = DT.AssignTargetingDescriptorRecord<$, TDs> const targetingParsers: $$['TargetingParsers'] = this .#mergeTargetingParsers( @@ -472,11 +464,11 @@ export default class Data<$ extends DT.Meta> #mergeTargetingParsers( targeting: TDs, - ): Merge<$['TargetingParsers'], TT.ParserRecord> { + ): Assign<$['TargetingParsers'], TT.ParserRecord> { return { ...this.targetingParsers, ...objectMap(targeting, ({ targetingParser }) => targetingParser), - } as Merge<$['TargetingParsers'], TT.ParserRecord> + } as Assign<$['TargetingParsers'], TT.ParserRecord> } #mergeTargetingPredicates(targeting: TT.DescriptorRecord) { @@ -491,11 +483,11 @@ export default class Data<$ extends DT.Meta> #mergeQueryPredicates( targeting: TDs, - ): Merge<$['QueryParsers'], QT.ParserRecord> { + ): Assign<$['QueryParsers'], QT.ParserRecord> { return { ...this.#queryParsers, ...objectMap(targeting, ({ queryParser }) => queryParser), - } as Merge<$['QueryParsers'], QT.ParserRecord> + } as Assign<$['QueryParsers'], QT.ParserRecord> } /** @@ -532,21 +524,9 @@ export default class Data<$ extends DT.Meta> async useFallThroughTargeting( targeting: TDs, ): Promise< - Data< - Merge<$, { - FallThroughTargetingParsers: Merge< - $['FallThroughTargetingParsers'], - FTTT.ParsersRecord - > - }> - > + Data>> > { - type $$ = Merge<$, { - FallThroughTargetingParsers: Merge< - $['FallThroughTargetingParsers'], - FTTT.ParsersRecord - > - }> + type $$ = DT.AssignFallThroughTargetingParsers<$, FTTT.ParsersRecord> const fallThroughTargetingParsers = this.#mergeFallThroughTargeting( targeting, @@ -570,7 +550,7 @@ export default class Data<$ extends DT.Meta> #mergeFallThroughTargeting( targeting: TDs, - ): Merge<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> { + ): Assign<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> { return { ...this.#fallThroughTargetingParsers, ...objectMap( @@ -580,7 +560,7 @@ export default class Data<$ extends DT.Meta> ? descriptorOrParser : descriptorOrParser.targetingParser, ), - } as Merge<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> + } as Assign<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> } /** diff --git a/packages/api/src/PromisedData.ts b/packages/api/src/PromisedData.ts index 06249ff12..7c4c1dd70 100644 --- a/packages/api/src/PromisedData.ts +++ b/packages/api/src/PromisedData.ts @@ -11,7 +11,6 @@ import type { DataItemIn } from './parsers/DataItem.ts' import type { ConfigurableData } from './ConfigurableData.ts' import type { InsertableData } from './InsertableData.ts' import type { QueryableData } from './QueryableData.ts' -import type { Merge } from './util.ts' /** * A Promise-based wrapper for Data that implements the ConfigurableData, @@ -79,7 +78,7 @@ export class PromisedData<$ extends DT.Meta> extends Promise> usePayload( parsers: Parsers, ): PromisedData< - Merge<$, { PayloadParsers: Merge<$['PayloadParsers'], Parsers> }> + DT.AssignPayloadParsers<$, Parsers> > { return this.#create((data) => data.usePayload(parsers)) } @@ -123,10 +122,7 @@ export class PromisedData<$ extends DT.Meta> extends Promise> useTargeting( targeting: TDs, ): PromisedData< - Merge<$, { - TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord> - QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord> - }> + DT.AssignTargetingDescriptorRecord<$, TDs> > { return this.#create((data) => data.useTargeting(targeting)) } @@ -142,12 +138,7 @@ export class PromisedData<$ extends DT.Meta> extends Promise> useFallThroughTargeting( targeting: TDs, ): PromisedData< - Merge<$, { - FallThroughTargetingParsers: Merge< - $['FallThroughTargetingParsers'], - FTTT.ParsersRecord - > - }> + DT.AssignFallThroughTargetingParsers<$, FTTT.ParsersRecord> > { return this.#create((data) => data.useFallThroughTargeting(targeting)) } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7c5f05c13..edc468cff 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -15,7 +15,6 @@ export type { ConfigurableData } from './ConfigurableData.ts' export type { InsertableData } from './InsertableData.ts' export type { QueryableData } from './QueryableData.ts' export type { StaticRecord } from './types.ts' -export type { Merge } from './util.ts' /** * Type helpers for the {@link Data} class. diff --git a/packages/api/src/types/Data.ts b/packages/api/src/types/Data.ts index 851c3f053..2d2715cbe 100644 --- a/packages/api/src/types/Data.ts +++ b/packages/api/src/types/Data.ts @@ -1,4 +1,7 @@ +import type { Assign } from 'utility-types' import type Data from '../Data.ts' +import type * as QT from './Query.ts' +import type * as TT from './Targeting.ts' import type * as FTTT from './FallThroughTargeting.ts' import type { $ZodShape, output } from 'zod/v4/core' @@ -23,6 +26,48 @@ export interface EmptyMeta { FallThroughTargetingParsers: {} } +export type AssignPayloadParsers<$ extends Meta, Parsers extends $ZodShape> = { + PayloadParsers: Assign<$['PayloadParsers'], Parsers> + TargetingParsers: $['TargetingParsers'] + QueryParsers: $['QueryParsers'] + FallThroughTargetingParsers: $['FallThroughTargetingParsers'] +} + +export type AssignTargetingParsers<$ extends Meta, Parsers extends $ZodShape> = + { + PayloadParsers: $['PayloadParsers'] + TargetingParsers: Assign<$['TargetingParsers'], Parsers> + QueryParsers: $['QueryParsers'] + FallThroughTargetingParsers: $['FallThroughTargetingParsers'] + } + +export type AssignQueryParsers<$ extends Meta, Parsers extends $ZodShape> = { + PayloadParsers: $['PayloadParsers'] + TargetingParsers: $['TargetingParsers'] + QueryParsers: Assign<$['QueryParsers'], Parsers> + FallThroughTargetingParsers: $['FallThroughTargetingParsers'] +} + +export type AssignFallThroughTargetingParsers< + $ extends Meta, + Parsers extends $ZodShape, +> = { + PayloadParsers: $['PayloadParsers'] + TargetingParsers: $['TargetingParsers'] + QueryParsers: $['QueryParsers'] + FallThroughTargetingParsers: Assign<$['FallThroughTargetingParsers'], Parsers> +} + +export type AssignTargetingDescriptorRecord< + $ extends Meta, + TDs extends TT.DescriptorRecord, +> = { + PayloadParsers: $['PayloadParsers'] + TargetingParsers: Assign<$['TargetingParsers'], TT.ParserRecord> + QueryParsers: Assign<$['QueryParsers'], QT.ParserRecord> + FallThroughTargetingParsers: $['FallThroughTargetingParsers'] +} + /** * Match any Data */ diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 1709680de..90eda1cad 100644 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -1,18 +1,6 @@ import type { L } from 'ts-toolbelt' import type { MaybePromise } from './types.ts' -/** - * Merge two object types, with B's properties taking precedence over A's. - * Unlike intersection (`A & B`), this produces a flat mapped type that - * TypeScript resolves eagerly, preventing type instantiation depth errors - * when chaining methods like `usePayload` and `useTargeting`. - */ -export type Merge = - & { - [K in Exclude & string]: A[K] - } - & B - export function objectMap, R>( obj: O, fn: (v: O[K], k: K) => R, From 46d75723fe7fcde566ae75071d3ac601840e6c3a Mon Sep 17 00:00:00 2001 From: John Wright Date: Mon, 20 Apr 2026 15:13:12 +0100 Subject: [PATCH 2/4] feat(api): move schema configuration into a DataSchema builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce DataSchema, a new class for declaring payload parsers, targeting descriptors, and fall-through targeting. Its chained methods accumulate parser shapes via intersection and materialise them into a BuiltDataSchema via .build(), which is then passed to Data.create(schema). This replaces the previous flow where Data.create() returned a chainable PromisedData with usePayload/useTargeting/useFallThroughTargeting methods. Chaining many of those methods accumulated mapped types via utility-types' Assign and hit TS2589 "Type instantiation is excessively deep" around 60–95 chained calls. DataSchema's intersection-based accumulation keeps 100 payloads + 30 targeting descriptors well within TypeScript's depth limits and type-checks in under a second. BREAKING CHANGE: Data.create() now requires a BuiltDataSchema argument. The schema configuration methods (usePayload, useTargeting, useFallThroughTargeting) have moved from Data/PromisedData to DataSchema. The ConfigurableData interface and the DT.Assign* type helpers have been removed from the public API. Before: ``` const data = await Data.create() .usePayload({ greeting: z.string() }) .useTargeting({ country: targetIncludes(z.string()) }) .addRules('greeting', [...]) ``` After: ``` const schema = DataSchema.create() .usePayload({ greeting: z.string() }) .useTargeting({ country: targetIncludes(z.string()) }) .build() const data = await Data.create(schema).addRules('greeting', [...]) ``` --- .github/copilot-instructions.md | 32 +- README.md | 155 ++++--- deno.json | 3 +- deno.lock | 7 +- packages/api/README.md | 202 ++++---- packages/api/src/ConfigurableData.ts | 63 --- packages/api/src/Data.ts | 300 ++---------- packages/api/src/DataSchema.ts | 182 ++++++++ packages/api/src/PromisedData.ts | 68 +-- packages/api/src/index.ts | 3 +- packages/api/src/predicates/equals.ts | 24 +- packages/api/src/predicates/targetIncludes.ts | 26 +- packages/api/src/types/Data.ts | 45 -- packages/api/test/Data.test.ts | 436 ++++++++++-------- packages/api/test/type-stress.test.ts | 149 ++++++ packages/client/README.md | 38 +- packages/client/src/index.ts | 2 +- packages/client/test/index.test.ts | 38 +- packages/date-range/README.md | 106 +++-- packages/date-range/src/index.ts | 26 +- packages/date-range/test/index.test.ts | 19 +- packages/explode/README.md | 43 +- packages/fs/README.md | 111 +++-- packages/fs/src/load.ts | 11 +- packages/fs/src/watch.ts | 11 +- packages/fs/test/fixtures/data.ts | 17 +- packages/json-schema/test/fixtures/data.ts | 41 +- packages/server/README.md | 153 +++--- packages/server/src/index.ts | 18 +- packages/server/test/index.test.ts | 76 +-- 30 files changed, 1284 insertions(+), 1121 deletions(-) delete mode 100644 packages/api/src/ConfigurableData.ts create mode 100644 packages/api/src/DataSchema.ts create mode 100644 packages/api/test/type-stress.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9420421bb..2bf2c7f38 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,9 +35,12 @@ See and built-in predicates in [packages/api/src/predicates/](packages/api/src/predicates/). -**PromisedData Pattern**: The `Data.create()` builder uses -[PromisedData](packages/api/src/PromisedData.ts) to enable fluent async -configuration before resolving to a `Data` instance. +**Two-phase API**: Schema setup uses +[DataSchema](packages/api/src/DataSchema.ts) (synchronous, accumulates parser +shapes via intersection for cheap type inference). `.build()` produces a +`BuiltDataSchema`, which `Data.create(schema)` turns into a +[PromisedData](packages/api/src/PromisedData.ts) — an awaitable chain for +`addRules`/`insert`/query operations. **Type Inference**: Heavy use of TypeScript's type system with Zod schemas. The `Data` class uses complex type inference (`DT.Meta`) to ensure payloads, @@ -137,10 +140,10 @@ cd packages/api && deno task test **Import Paths**: Use `.ts` extensions in imports, even though it's Deno. Use workspace references like `@targetd/api` in package dependencies. -**Zod Schemas**: For new code and public examples, prefer `import { z } from 'zod'` -for consistency with the root README and package READMEs. Some internal, -performance-sensitive modules may instead use `zod/mini` for schemas and -`zod/v4/core` for types; when editing those files (e.g. +**Zod Schemas**: For new code and public examples, prefer +`import { z } from 'zod'` for consistency with the root README and package +READMEs. Some internal, performance-sensitive modules may instead use `zod/mini` +for schemas and `zod/v4/core` for types; when editing those files (e.g. [packages/api/src/Data.ts](packages/api/src/Data.ts#L22-L25)), follow the existing local style. @@ -190,16 +193,19 @@ challenging: ```typescript // Instead of chaining everything -const data = await Data.create() - .usePayload({ foo: z.string() }) - .useTargeting({ bar: targetIncludes(z.string()) }) - .addRules('foo', rules) +const data = await Data.create( + DataSchema.create() + .usePayload({ foo: z.string() }) + .useTargeting({ bar: targetIncludes(z.string()) }) + .build(), +).addRules('foo', rules) // Break it down to inspect types -const step1 = Data.create() +const step1 = DataSchema.create() const step2 = step1.usePayload({ foo: z.string() }) const step3 = step2.useTargeting({ bar: targetIncludes(z.string()) }) -type Step3Type = Awaited // Inspect in IDE +const built = step3.build() +type BuiltType = typeof built // Inspect in IDE ``` **Strategy 2: Use helper types from `types/` directories** diff --git a/README.md b/README.md index da0d4e266..a6a0dcd1b 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,19 @@ Core targeting and data querying API. Define payloads, targeting rules, and query logic. ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ greeting: z.string() }) .useTargeting({ country: targetIncludes(z.string()) }) - .addRules('greeting', [ - { targeting: { country: ['US'] }, payload: 'Hello!' }, - { targeting: { country: ['ES'] }, payload: '¡Hola!' }, - { payload: 'Hi!' }, - ]) + .build() + +const data = await Data.create(schema).addRules('greeting', [ + { targeting: { country: ['US'] }, payload: 'Hello!' }, + { targeting: { country: ['ES'] }, payload: '¡Hola!' }, + { payload: 'Hi!' }, +]) await data.getPayload('greeting', { country: 'US' }) // 'Hello!' ``` @@ -93,14 +95,17 @@ Built-in targeting descriptor for date range queries. ```typescript import dateRangeTargeting from '@targetd/date-range' -const data = await Data.create() +const schema = DataSchema.create() + .usePayload({ campaign: z.string() }) .useTargeting({ date: dateRangeTargeting }) - .addRules('campaign', [ - { - targeting: { date: { start: '2024-12-01', end: '2024-12-31' } }, - payload: 'Holiday Campaign', - }, - ]) + .build() + +const data = await Data.create(schema).addRules('campaign', [ + { + targeting: { date: { start: '2024-12-01', end: '2024-12-31' } }, + payload: 'Holiday Campaign', + }, +]) ``` **[View Documentation →](./packages/date-range)** @@ -144,10 +149,10 @@ npx jsr add @targetd/api @targetd/fs **1. Define your data:** ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { z } from 'zod' -export const data = await Data.create() +const schema = DataSchema.create() .usePayload({ banner: z.string(), feature: z.object({ @@ -159,6 +164,9 @@ export const data = await Data.create() platform: targetIncludes(z.string()), isPremium: targetIncludes(z.boolean()), }) + .build() + +export const data = await Data.create(schema) .addRules('banner', [ { targeting: { platform: ['mobile'] }, payload: '📱 Mobile Banner' }, { targeting: { platform: ['desktop'] }, payload: '🖥 Desktop Banner' }, @@ -200,75 +208,90 @@ const allPayloads = await client.getPayloadForEachName({ isPremium: true }) ### Feature Flags ```typescript -const data = await Data.create() - .usePayload({ newFeature: z.boolean() }) - .useTargeting({ userTier: targetEquals(z.string()) }) - .addRules('newFeature', [ - { targeting: { userTier: 'beta' }, payload: true }, - { payload: false }, - ]) +const data = await Data.create( + DataSchema.create() + .usePayload({ newFeature: z.boolean() }) + .useTargeting({ userTier: targetEquals(z.string()) }) + .build(), +).addRules('newFeature', [ + { targeting: { userTier: 'beta' }, payload: true }, + { payload: false }, +]) ``` ### A/B Testing ```typescript -const data = await Data.create() - .usePayload({ variant: z.string() }) - .useTargeting({ userId: targetIncludes(z.string()) }) - .addRules('variant', [ - { targeting: { userId: experimentGroup }, payload: 'variant-a' }, - { payload: 'variant-b' }, - ]) +const data = await Data.create( + DataSchema.create() + .usePayload({ variant: z.string() }) + .useTargeting({ userId: targetIncludes(z.string()) }) + .build(), +).addRules('variant', [ + { targeting: { userId: experimentGroup }, payload: 'variant-a' }, + { payload: 'variant-b' }, +]) ``` ### Content Personalization ```typescript -const data = await Data.create() - .usePayload({ content: z.string() }) - .useTargeting({ - region: targetIncludes(z.string()), - language: targetIncludes(z.string()), - }) - .addRules('content', [ - { - targeting: { region: ['US'], language: ['en'] }, - payload: 'US English content', - }, - { - targeting: { region: ['US'], language: ['es'] }, - payload: 'US Spanish content', - }, - { payload: 'Default content' }, - ]) +const data = await Data.create( + DataSchema.create() + .usePayload({ content: z.string() }) + .useTargeting({ + region: targetIncludes(z.string()), + language: targetIncludes(z.string()), + }) + .build(), +).addRules('content', [ + { + targeting: { region: ['US'], language: ['en'] }, + payload: 'US English content', + }, + { + targeting: { region: ['US'], language: ['es'] }, + payload: 'US Spanish content', + }, + { payload: 'Default content' }, +]) ``` ### Configuration Management ```typescript -const data = await Data.create() - .usePayload({ - config: z.object({ - apiUrl: z.string(), - timeout: z.number(), - }), - }) - .useTargeting({ environment: targetEquals(z.string()) }) - .addRules('config', [ - { - targeting: { environment: 'production' }, - payload: { apiUrl: 'https://api.prod.com', timeout: 5000 }, - }, - { - targeting: { environment: 'staging' }, - payload: { apiUrl: 'https://api.staging.com', timeout: 10000 }, - }, - { payload: { apiUrl: 'http://localhost:3000', timeout: 30000 } }, - ]) +const data = await Data.create( + DataSchema.create() + .usePayload({ + config: z.object({ + apiUrl: z.string(), + timeout: z.number(), + }), + }) + .useTargeting({ environment: targetEquals(z.string()) }) + .build(), +).addRules('config', [ + { + targeting: { environment: 'production' }, + payload: { apiUrl: 'https://api.prod.com', timeout: 5000 }, + }, + { + targeting: { environment: 'staging' }, + payload: { apiUrl: 'https://api.staging.com', timeout: 10000 }, + }, + { payload: { apiUrl: 'http://localhost:3000', timeout: 30000 } }, +]) ``` ## Core Concepts +### Schema Configuration + +Use `DataSchema` to declare payload schemas and targeting descriptors, then +`.build()` and pass the result to `Data.create()`. Splitting the schema and data +phases keeps TypeScript compilation cheap even with hundreds of payloads and +targeting descriptors. + ### Payloads Define what data you want to serve using Zod schemas. diff --git a/deno.json b/deno.json index acec7e856..f15a910d0 100644 --- a/deno.json +++ b/deno.json @@ -41,7 +41,6 @@ "imports": { "@commitlint/cli": "npm:@commitlint/cli@^20.5.0", "@commitlint/config-conventional": "npm:@commitlint/config-conventional@^20.5.0", - "lint-staged": "npm:lint-staged@^16.4.0", - "utility-types": "npm:utility-types@^3.11.0" + "lint-staged": "npm:lint-staged@^16.4.0" } } diff --git a/deno.lock b/deno.lock index 422b6d019..eebd4c797 100644 --- a/deno.lock +++ b/deno.lock @@ -32,7 +32,6 @@ "npm:release-please@*": "17.3.0", "npm:supertest@*": "7.2.2", "npm:ts-toolbelt@^9.6.0": "9.6.0", - "npm:utility-types@^3.11.0": "3.11.0", "npm:yaml@^2.8.2": "2.8.2", "npm:yargs@18": "18.0.0", "npm:zod@^4.3.6": "4.3.6" @@ -1993,9 +1992,6 @@ "unpipe@1.0.0": { "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, - "utility-types@3.11.0": { - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==" - }, "validate-npm-package-license@3.0.4": { "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dependencies": [ @@ -2093,8 +2089,7 @@ "dependencies": [ "npm:@commitlint/cli@^20.5.0", "npm:@commitlint/config-conventional@^20.5.0", - "npm:lint-staged@^16.4.0", - "npm:utility-types@^3.11.0" + "npm:lint-staged@^16.4.0" ], "members": { "packages/api": { diff --git a/packages/api/README.md b/packages/api/README.md index 67b68061c..08afbce35 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -25,32 +25,35 @@ for serving different payloads based on targeting conditions. It's ideal for: ### Creating a Data Store -Start by creating a data store with `Data.create()`: +Configure payload and targeting schemas with `DataSchema`, then pass the built +config to `Data.create()`: ```typescript -import { Data, targetEquals, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetEquals, targetIncludes } from '@targetd/api' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ greeting: z.string(), }) .useTargeting({ country: targetIncludes(z.string()), }) - .addRules('greeting', [ - { - targeting: { country: ['US'] }, - payload: 'Hello!', - }, - { - targeting: { country: ['ES'] }, - payload: '¡Hola!', - }, - { - payload: 'Hi!', // default fallback - }, - ]) + .build() + +const data = await Data.create(schema).addRules('greeting', [ + { + targeting: { country: ['US'] }, + payload: 'Hello!', + }, + { + targeting: { country: ['ES'] }, + payload: '¡Hola!', + }, + { + payload: 'Hi!', // default fallback + }, +]) // Query the data const greeting = await data.getPayload('greeting', { country: 'US' }) @@ -60,6 +63,11 @@ const defaultGreeting = await data.getPayload('greeting') // Returns: 'Hi!' ``` +Schema configuration (`DataSchema`) and data operations (`Data`) are split so +that TypeScript only has to resolve the accumulated parser types once, at +`.build()`. This keeps compilation cheap even when hundreds of payloads and +targeting descriptors are chained together. + ## Core Concepts ### 1. Payloads @@ -68,7 +76,7 @@ Define the types of data your store will manage using [Zod](https://zod.dev/) schemas: ```typescript -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ message: z.string(), config: z.object({ @@ -76,6 +84,7 @@ const data = await Data.create() maxRetries: z.number(), }), }) + .build() ``` ### 2. Targeting @@ -87,27 +96,31 @@ built-in predicates or create custom ones: - **`targetIncludes`**: Check if a value is in an array ```typescript - .useTargeting({ - channels: targetIncludes(z.string()) - }) - .addRules('content', [ + const schema = DataSchema.create() + .usePayload({ content: z.string() }) + .useTargeting({ channels: targetIncludes(z.string()) }) + .build() + + const data = await Data.create(schema).addRules('content', [ { targeting: { channels: ['mobile', 'web'] }, - payload: 'Multi-platform content' - } + payload: 'Multi-platform content', + }, ]) ``` - **`targetEquals`**: Check for exact equality ```typescript - .useTargeting({ - isPremium: targetEquals(z.boolean()) - }) - .addRules('feature', [ + const schema = DataSchema.create() + .usePayload({ feature: z.string() }) + .useTargeting({ isPremium: targetEquals(z.boolean()) }) + .build() + + const data = await Data.create(schema).addRules('feature', [ { targeting: { isPremium: true }, - payload: 'Premium feature' - } + payload: 'Premium feature', + }, ]) ``` @@ -116,7 +129,7 @@ built-in predicates or create custom ones: Create custom targeting logic with predicates: ```typescript -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ message: z.string() }) .useTargeting({ timeOfDay: { @@ -127,12 +140,14 @@ const data = await Data.create() targetingParser: z.enum(['morning', 'afternoon', 'evening']), }, }) - .addRules('message', [ - { - targeting: { timeOfDay: 'morning' }, - payload: 'Good morning!', - }, - ]) + .build() + +const data = await Data.create(schema).addRules('message', [ + { + targeting: { timeOfDay: 'morning' }, + payload: 'Good morning!', + }, +]) ``` #### Async Predicates @@ -140,15 +155,15 @@ const data = await Data.create() Predicates can be asynchronous: ```typescript -.useTargeting({ +DataSchema.create().useTargeting({ hasAccess: { predicate: (userId) => async (requiredRole) => { const user = await fetchUser(userId) return user.role === requiredRole }, queryParser: z.string(), - targetingParser: z.string() - } + targetingParser: z.string(), + }, }) ``` @@ -157,17 +172,17 @@ Predicates can be asynchronous: Set `requiresQuery: false` for predicates that don't need query parameters: ```typescript -import { createTargetingDescriptor } from '@targetd/api' +import { createTargetingDescriptor, DataSchema } from '@targetd/api' -.useTargeting({ +DataSchema.create().useTargeting({ currentTime: createTargetingDescriptor({ predicate: () => (targetTime) => { return new Date().getHours() === targetTime }, queryParser: z.undefined(), targetingParser: z.number(), - requiresQuery: false - }) + requiresQuery: false, + }), }) ``` @@ -277,7 +292,7 @@ This is useful in distributed systems where different services have access to different context: ```typescript -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ message: z.string() }) .useTargeting({ channel: targetIncludes(z.string()), @@ -285,21 +300,23 @@ const data = await Data.create() .useFallThroughTargeting({ region: z.array(z.string()), }) - .addRules('message', [ - { - targeting: { - channel: ['mobile'], - region: ['EU'], // fall-through - }, - payload: 'EU mobile message', + .build() + +const data = await Data.create(schema).addRules('message', [ + { + targeting: { + channel: ['mobile'], + region: ['EU'], // fall-through }, - { - targeting: { - channel: ['mobile'], - }, - payload: 'Mobile message', + payload: 'EU mobile message', + }, + { + targeting: { + channel: ['mobile'], }, - ]) + payload: 'Mobile message', + }, +]) // Query with only the regular targeting field const result = await data.getPayload('message', { channel: 'mobile' }) @@ -313,14 +330,16 @@ fall-through targeting conditions with its own context: ```typescript // In the receiving service with region context -const receivingServiceData = await Data.create() +const receivingConfig = DataSchema.create() .usePayload({ message: z.string() }) .useTargeting({ region: targetIncludes(z.string()), }) - .insert({ - message: result, // The __rules__ structure from the first service - }) + .build() + +const receivingServiceData = await Data.create(receivingConfig).insert({ + message: result, // The __rules__ structure from the first service +}) // Now evaluate with region context const finalPayload = await receivingServiceData.getPayload('message', { @@ -383,17 +402,19 @@ const empty = data.removeAllRules() Use `withNegate` option to support negative targeting: ```typescript -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ content: z.string() }) .useTargeting({ platform: targetIncludes(z.string(), { withNegate: true }), }) - .addRules('content', [ - { - targeting: { platform: ['!mobile'] }, - payload: 'Desktop-only content', - }, - ]) + .build() + +const data = await Data.create(schema).addRules('content', [ + { + targeting: { platform: ['!mobile'] }, + payload: 'Desktop-only content', + }, +]) // Matches everything except mobile await data.getPayload('content', { platform: 'desktop' }) @@ -402,33 +423,35 @@ await data.getPayload('content', { platform: 'desktop' }) ### Complex Multi-condition Rules ```typescript -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ experience: z.string() }) .useTargeting({ weather: targetIncludes(z.string()), tide: targetEquals(z.boolean()), wind: targetEquals(z.string()), }) - .addRules('experience', [ - { - // All conditions must match (AND) - targeting: { - weather: ['sunny'], - tide: true, - wind: 'strong', - }, - payload: 'Perfect surfing conditions! 🏄‍♂️', - }, - { - targeting: { - weather: ['sunny'], - }, - payload: 'Nice day! 😎', + .build() + +const data = await Data.create(schema).addRules('experience', [ + { + // All conditions must match (AND) + targeting: { + weather: ['sunny'], + tide: true, + wind: 'strong', }, - { - payload: 'Regular day', + payload: 'Perfect surfing conditions! 🏄‍♂️', + }, + { + targeting: { + weather: ['sunny'], }, - ]) + payload: 'Nice day! 😎', + }, + { + payload: 'Regular day', + }, +]) ``` ## Type Safety @@ -436,9 +459,12 @@ const data = await Data.create() The library provides full TypeScript type inference: ```typescript -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ message: z.string() }) .useTargeting({ country: targetIncludes(z.string()) }) + .build() + +const data = await Data.create(schema) // ✅ Type-safe await data.getPayload('message', { country: 'US' }) diff --git a/packages/api/src/ConfigurableData.ts b/packages/api/src/ConfigurableData.ts deleted file mode 100644 index 13259239f..000000000 --- a/packages/api/src/ConfigurableData.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { $ZodShape } from 'zod/v4/core' -import type * as DT from './types/Data.ts' -import type * as FTTT from './types/FallThroughTargeting.ts' -import type * as TT from './types/Targeting.ts' - -/** - * Interface for configuring a {@link Data} instance with payload schemas, - * targeting descriptors, and fall-through targeting. - * - * @template $ - The metadata type extending {@link DT.Meta} - * - * @example - * ```ts - * import { Data, targetIncludes } from '@targetd/api' - * import { z } from 'zod' - * - * const data = await Data.create() - * .usePayload({ greeting: z.string() }) - * .useTargeting({ country: targetIncludes(z.string()) }) - * ``` - */ -export interface ConfigurableData<$ extends DT.Meta> { - /** - * Register additional payload parsers for validating payloads. - * - * @param parsers - An object of Zod schemas keyed by payload name. - * @returns A new instance with the updated payload parsers. - */ - usePayload( - parsers: Parsers, - ): Promise< - ConfigurableData< - DT.AssignPayloadParsers<$, Parsers> - > - > - - /** - * Register targeting descriptors to enable query-based filtering. - * - * @param targeting - An object of targeting descriptors keyed by targeting name. - * @returns A new instance with the updated targeting and query parsers. - */ - useTargeting(targeting: TDs): Promise< - ConfigurableData< - DT.AssignTargetingDescriptorRecord<$, TDs> - > - > - - /** - * Register fall-through targeting descriptors. - * Fall-through targeting allows rules to continue matching after a successful match. - * - * @param targeting - An object of fall-through targeting descriptors. - * @returns A new instance with the updated fall-through targeting parsers. - */ - useFallThroughTargeting( - targeting: TDs, - ): Promise< - ConfigurableData< - DT.AssignFallThroughTargetingParsers<$, FTTT.ParsersRecord> - > - > -} diff --git a/packages/api/src/Data.ts b/packages/api/src/Data.ts index 9c4ef2141..206110913 100644 --- a/packages/api/src/Data.ts +++ b/packages/api/src/Data.ts @@ -7,14 +7,12 @@ import { objectMap, objectSize, } from './util.ts' -import type { Assign } from 'utility-types' import { type DataItemsOut, DataItemsParser } from './parsers/DataItems.ts' import type { DataItemIn, DataItemOut } from './parsers/DataItem.ts' import type { DataItemRule } from './parsers/DataItemRule.ts' import type { MaybeArray, MaybePromise, ZodPartialObject } from './types.ts' import type { DataItemRulesIn } from './parsers/DataItemRules.ts' import type * as DT from './types/Data.ts' -import type * as TT from './types/Targeting.ts' import type * as FTTT from './types/FallThroughTargeting.ts' import type * as PT from './types/Payload.ts' import type * as QT from './types/Query.ts' @@ -22,38 +20,40 @@ import { type $InferObjectOutput, type $strict, type $ZodOptional, - type $ZodShape, - $ZodType, + type $ZodType, type output, } from 'zod/v4/core' import { partial, strictObject } from 'zod/mini' import { PromisedData } from './PromisedData.ts' import { resolveVariables } from './parsers/DataItemVariableResolver.ts' -import type { ConfigurableData } from './ConfigurableData.ts' import type { InsertableData } from './InsertableData.ts' import type { QueryableData } from './QueryableData.ts' +import type { BuiltDataSchema } from './DataSchema.ts' /** - * In-memory data store. + * In-memory data store. Configure payload and targeting schemas with + * {@link DataSchema}, then pass the built schema to {@link Data.create}. * * @example * ```ts * import { z } from 'zod/mini' - * import { targetIncludes } from '@targetd/api' + * import { Data, DataSchema, targetIncludes } from '@targetd/api' * import { assertEquals } from 'jsr:@std/assert' * - * const data = await Data.create() + * const schema = DataSchema.create() * .usePayload({ foo: z.string() }) * .useTargeting({ channel: targetIncludes(z.string()) }) - * .addRules('foo', [ - * { - * targeting: { channel: ['news'] }, - * payload: 'bar' - * }, - * { - * payload: 'foo' - * } - * ]) + * .build() + * + * const data = await Data.create(schema).addRules('foo', [ + * { + * targeting: { channel: ['news'] }, + * payload: 'bar' + * }, + * { + * payload: 'foo' + * } + * ]) * * assertEquals( * await data.getPayloadForEachName({ channel: 'news' }), @@ -62,7 +62,7 @@ import type { QueryableData } from './QueryableData.ts' * ``` */ export default class Data<$ extends DT.Meta> - implements ConfigurableData<$>, InsertableData<$>, QueryableData<$> { + implements InsertableData<$>, QueryableData<$> { readonly #fallThroughTargetingParsers: $['FallThroughTargetingParsers'] readonly #data: DataItemsOut<$> readonly #payloadParsers: $['PayloadParsers'] @@ -72,22 +72,38 @@ export default class Data<$ extends DT.Meta> readonly #QueryParser: ZodPartialObject<$['QueryParsers']> /** - * Create a new empty Data instance. + * Create a new empty Data instance from a {@link BuiltDataSchema}. * - * @returns A PromisedData instance that can be configured with payloads, targeting, and rules. + * @param schema - A schema produced by {@link DataSchema.build}. + * @returns A PromisedData instance ready for rules, inserts, and queries. * * @example * ```ts - * import { Data } from '@targetd/api' + * import { Data, DataSchema } from '@targetd/api' * import { z } from 'zod' * - * const data = await Data.create() + * const schema = DataSchema.create() * .usePayload({ greeting: z.string() }) - * .addRules('greeting', [{ payload: 'Hello!' }]) + * .build() + * + * const data = await Data.create(schema).addRules('greeting', [ + * { payload: 'Hello!' }, + * ]) * ``` */ - static create(): PromisedData { - return PromisedData.create(new Data({}, {}, {}, {}, {}, {})) + static create<$ extends DT.Meta>( + schema: BuiltDataSchema<$>, + ): PromisedData<$> { + return PromisedData.create( + new Data<$>( + {} as DataItemsOut<$>, + schema.payloadParsers, + schema.targetingPredicates, + schema.targetingParsers, + schema.queryParsers, + schema.fallThroughTargetingParsers, + ), + ) } /** @@ -175,59 +191,6 @@ export default class Data<$ extends DT.Meta> return this.#fallThroughTargetingParsers } - /** - * Define payload types using Zod schemas. This determines what data can be stored in the Data instance. - * - * @param parsers - Object mapping payload names to Zod schemas. - * @returns A new Data instance with the payload parsers registered. - * - * @example - * ```ts - * import { Data } from '@targetd/api' - * import { z } from 'zod' - * - * const data = await Data.create() - * .usePayload({ - * message: z.string(), - * config: z.object({ - * enabled: z.boolean(), - * maxRetries: z.number() - * }) - * }) - * ``` - */ - async usePayload(parsers: Parsers): Promise< - Data> - > { - type $$ = DT.AssignPayloadParsers<$, Parsers> - - const payloadParsers = this.#mergePayloadParsers(parsers) - - const data = (await DataItemsParser( - payloadParsers, - this.#targetingParsers, - this.#fallThroughTargetingParsers, - ).parseAsync(this.#data)) as DataItemsOut<$$> - - return new Data<$$>( - data, - payloadParsers, - this.#targetingPredicates, - this.#targetingParsers, - this.#queryParsers, - this.#fallThroughTargetingParsers, - ) - } - - #mergePayloadParsers( - parsers: Parsers, - ): Assign<$['PayloadParsers'], Parsers> { - return { - ...this.#payloadParsers, - ...parsers, - } as Assign<$['PayloadParsers'], Parsers> - } - /** * Insert data from another Data instance or add new rules. Commonly used with fall-through targeting * to pass unresolved rules between services. @@ -308,14 +271,16 @@ export default class Data<$ extends DT.Meta> * * @example * ```ts - * const data = await Data.create() - * .usePayload({ greeting: z.string() }) - * .useTargeting({ country: targetIncludes(z.string()) }) - * .addRules('greeting', [ - * { targeting: { country: ['US'] }, payload: 'Hello!' }, - * { targeting: { country: ['ES'] }, payload: '¡Hola!' }, - * { payload: 'Hi!' } // default fallback - * ]) + * const data = await Data.create( + * DataSchema.create() + * .usePayload({ greeting: z.string() }) + * .useTargeting({ country: targetIncludes(z.string()) }) + * .build(), + * ).addRules('greeting', [ + * { targeting: { country: ['US'] }, payload: 'Hello!' }, + * { targeting: { country: ['ES'] }, payload: '¡Hola!' }, + * { payload: 'Hi!' } // default fallback + * ]) * ``` * * @example With variables: @@ -400,169 +365,6 @@ export default class Data<$ extends DT.Meta> ) } - /** - * Define targeting descriptors that determine how rules are matched against queries. - * - * @param targeting - Object mapping targeting keys to targeting descriptors with predicate functions and parsers. - * @returns A new Data instance with the targeting descriptors registered. - * - * @example Using built-in predicates: - * ```ts - * import { targetIncludes, targetEquals } from '@targetd/api' - * - * const data = await Data.create() - * .useTargeting({ - * country: targetIncludes(z.string()), - * isPremium: targetEquals(z.boolean()) - * }) - * ``` - * - * @example Custom targeting descriptor: - * ```ts - * .useTargeting({ - * timeOfDay: { - * predicate: (queryTime) => (targetTime) => queryTime === targetTime, - * queryParser: z.enum(['morning', 'afternoon', 'evening']), - * targetingParser: z.enum(['morning', 'afternoon', 'evening']) - * } - * }) - * ``` - */ - async useTargeting(targeting: TDs): Promise< - Data> - > { - type $$ = DT.AssignTargetingDescriptorRecord<$, TDs> - - const targetingParsers: $$['TargetingParsers'] = this - .#mergeTargetingParsers( - targeting, - ) - - const targetingPredicates = this.#mergeTargetingPredicates( - targeting, - ) as TargetingPredicates<$$> - - const queryParsers: $$['QueryParsers'] = this.#mergeQueryPredicates( - targeting, - ) - - const data = await DataItemsParser( - this.#payloadParsers, - targetingParsers, - this.#fallThroughTargetingParsers, - ).parseAsync(this.#data) as DataItemsOut<$$> - - return new Data<$$>( - data, - this.#payloadParsers, - targetingPredicates, - targetingParsers, - queryParsers, - this.#fallThroughTargetingParsers, - ) - } - - #mergeTargetingParsers( - targeting: TDs, - ): Assign<$['TargetingParsers'], TT.ParserRecord> { - return { - ...this.targetingParsers, - ...objectMap(targeting, ({ targetingParser }) => targetingParser), - } as Assign<$['TargetingParsers'], TT.ParserRecord> - } - - #mergeTargetingPredicates(targeting: TT.DescriptorRecord) { - return { - ...this.#targetingPredicates, - ...objectMap(targeting, (targetingDescriptor) => ({ - predicate: targetingDescriptor.predicate, - requiresQuery: targetingDescriptor.requiresQuery ?? true, - })), - } - } - - #mergeQueryPredicates( - targeting: TDs, - ): Assign<$['QueryParsers'], QT.ParserRecord> { - return { - ...this.#queryParsers, - ...objectMap(targeting, ({ queryParser }) => queryParser), - } as Assign<$['QueryParsers'], QT.ParserRecord> - } - - /** - * Define fall-through targeting for rules that cannot be fully evaluated in the current service. - * These targeting conditions will be passed to another service for evaluation. - * - * @param targeting - Object mapping targeting keys to Zod parsers or targeting descriptors. - * @returns A new Data instance with fall-through targeting registered. - * - * @example - * ```ts - * const data = await Data.create() - * .useTargeting({ - * channel: targetIncludes(z.string()) - * }) - * .useFallThroughTargeting({ - * region: z.array(z.string()) - * }) - * .addRules('message', [ - * { - * targeting: { - * channel: ['mobile'], - * region: ['EU'] // fall-through - * }, - * payload: 'EU mobile message' - * } - * ]) - * - * const result = await data.getPayload('message', { channel: 'mobile' }) - * // Returns: { __rules__: [...], __variables__: {...} } - * // Can be passed to another service with region context - * ``` - */ - async useFallThroughTargeting( - targeting: TDs, - ): Promise< - Data>> - > { - type $$ = DT.AssignFallThroughTargetingParsers<$, FTTT.ParsersRecord> - - const fallThroughTargetingParsers = this.#mergeFallThroughTargeting( - targeting, - ) - - const data = (await DataItemsParser( - this.#payloadParsers, - this.#targetingParsers, - fallThroughTargetingParsers, - ).parseAsync(this.#data)) as DataItemsOut<$$> - - return new Data<$$>( - data, - this.#payloadParsers, - this.#targetingPredicates, - this.#targetingParsers, - this.#queryParsers, - fallThroughTargetingParsers, - ) - } - - #mergeFallThroughTargeting( - targeting: TDs, - ): Assign<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> { - return { - ...this.#fallThroughTargetingParsers, - ...objectMap( - targeting, - (descriptorOrParser) => - descriptorOrParser instanceof $ZodType - ? descriptorOrParser - : descriptorOrParser.targetingParser, - ), - } as Assign<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> - } - /** * Get payloads for all registered payload names at once. * diff --git a/packages/api/src/DataSchema.ts b/packages/api/src/DataSchema.ts new file mode 100644 index 000000000..1bffd4453 --- /dev/null +++ b/packages/api/src/DataSchema.ts @@ -0,0 +1,182 @@ +import { type $ZodShape, $ZodType } from 'zod/v4/core' +import type TargetingPredicates from './parsers/TargetingPredicates.ts' +import type * as DT from './types/Data.ts' +import type * as TT from './types/Targeting.ts' +import type * as FTTT from './types/FallThroughTargeting.ts' +import type * as QT from './types/Query.ts' +import { objectMap } from './util.ts' + +/** + * Opaque, frozen output of {@link DataSchema.build}. + * Pass this to {@link Data.create} to get a typed {@link Data} instance. + * + * The type parameter is a full {@link DT.Meta} — the four accumulated parser + * shapes from the builder, materialised into the concrete structure + * {@link Data} expects. + */ +export interface BuiltDataSchema<$ extends DT.Meta> { + readonly payloadParsers: $['PayloadParsers'] + readonly targetingParsers: $['TargetingParsers'] + readonly queryParsers: $['QueryParsers'] + readonly fallThroughTargetingParsers: $['FallThroughTargetingParsers'] + readonly targetingPredicates: TargetingPredicates<$> +} + +/** + * Fluent builder for configuring a {@link Data} instance. + * + * Each method returns a new builder with intersection-accumulated type + * parameters — this avoids the deep mapped-type instantiation that caused + * TS2589 when chaining many schema calls directly on {@link Data}. + * + * @example + * ```ts + * const schema = DataSchema.create() + * .usePayload({ greeting: z.string() }) + * .useTargeting({ country: targetIncludes(z.string()) }) + * .build() + * + * const data = await Data.create(schema).addRules('greeting', [ + * { targeting: { country: ['US'] }, payload: 'Hello!' }, + * ]) + * ``` + */ +export class DataSchema< + PP extends $ZodShape = {}, + TP extends $ZodShape = {}, + QP extends $ZodShape = {}, + FP extends $ZodShape = {}, +> { + readonly #payloadParsers: PP + readonly #targetingParsers: TP + readonly #queryParsers: QP + readonly #fallThroughTargetingParsers: FP + readonly #targetingPredicates: Record any + requiresQuery: boolean + }> + + private constructor( + payloadParsers: PP, + targetingParsers: TP, + queryParsers: QP, + fallThroughTargetingParsers: FP, + targetingPredicates: Record any + requiresQuery: boolean + }>, + ) { + this.#payloadParsers = payloadParsers + this.#targetingParsers = targetingParsers + this.#queryParsers = queryParsers + this.#fallThroughTargetingParsers = fallThroughTargetingParsers + this.#targetingPredicates = targetingPredicates + } + + /** + * Start a new empty configuration. + */ + static create(): DataSchema { + return new DataSchema({}, {}, {}, {}, {}) + } + + /** + * Register additional payload parsers. + */ + usePayload

( + parsers: P, + ): DataSchema { + return new DataSchema( + { ...this.#payloadParsers, ...parsers } as PP & P, + this.#targetingParsers, + this.#queryParsers, + this.#fallThroughTargetingParsers, + this.#targetingPredicates, + ) + } + + /** + * Register targeting descriptors. + */ + useTargeting( + targeting: TDs, + ): DataSchema< + PP, + TP & TT.ParserRecord, + QP & QT.ParserRecord, + FP + > { + const nextTargetingParsers = { + ...this.#targetingParsers, + ...objectMap(targeting, ({ targetingParser }) => targetingParser), + } as TP & TT.ParserRecord + const nextQueryParsers = { + ...this.#queryParsers, + ...objectMap(targeting, ({ queryParser }) => queryParser), + } as QP & QT.ParserRecord + const nextPredicates = { + ...this.#targetingPredicates, + ...objectMap(targeting, (descriptor) => ({ + predicate: descriptor.predicate, + requiresQuery: descriptor.requiresQuery ?? true, + })), + } + return new DataSchema( + this.#payloadParsers, + nextTargetingParsers, + nextQueryParsers, + this.#fallThroughTargetingParsers, + nextPredicates, + ) + } + + /** + * Register fall-through targeting descriptors. + */ + useFallThroughTargeting( + targeting: TDs, + ): DataSchema> { + const next = { + ...this.#fallThroughTargetingParsers, + ...objectMap( + targeting, + (descriptorOrParser) => + descriptorOrParser instanceof $ZodType + ? descriptorOrParser + : descriptorOrParser.targetingParser, + ), + } as FP & FTTT.ParsersRecord + return new DataSchema( + this.#payloadParsers, + this.#targetingParsers, + this.#queryParsers, + next, + this.#targetingPredicates, + ) + } + + /** + * Materialise the accumulated configuration into a {@link BuiltDataSchema} + * suitable for passing to {@link Data.create}. + */ + build(): BuiltDataSchema<{ + PayloadParsers: PP + TargetingParsers: TP + QueryParsers: QP + FallThroughTargetingParsers: FP + }> { + type $ = { + PayloadParsers: PP + TargetingParsers: TP + QueryParsers: QP + FallThroughTargetingParsers: FP + } + return Object.freeze({ + payloadParsers: this.#payloadParsers, + targetingParsers: this.#targetingParsers, + queryParsers: this.#queryParsers, + fallThroughTargetingParsers: this.#fallThroughTargetingParsers, + targetingPredicates: this.#targetingPredicates as TargetingPredicates<$>, + }) + } +} diff --git a/packages/api/src/PromisedData.ts b/packages/api/src/PromisedData.ts index 7c4c1dd70..27cad855f 100644 --- a/packages/api/src/PromisedData.ts +++ b/packages/api/src/PromisedData.ts @@ -1,33 +1,28 @@ -import type { $ZodShape } from 'zod/v4/core' import type Data from './Data.ts' import type * as DT from './types/Data.ts' import type * as PT from './types/Payload.ts' -import type * as TT from './types/Targeting.ts' -import type * as FTTT from './types/FallThroughTargeting.ts' import type * as QT from './types/Query.ts' import type { DataItemRulesIn } from './parsers/DataItemRules.ts' import type { MaybePromise } from './types.ts' import type { DataItemIn } from './parsers/DataItem.ts' -import type { ConfigurableData } from './ConfigurableData.ts' import type { InsertableData } from './InsertableData.ts' import type { QueryableData } from './QueryableData.ts' /** - * A Promise-based wrapper for Data that implements the ConfigurableData, - * InsertableData, and QueryableData interfaces. - * Allows chaining operations on Data instances that may be resolved asynchronously. - * All methods return a new PromisedData instance, enabling fluent API usage. + * A Promise-based wrapper for Data that implements the InsertableData and + * QueryableData interfaces. Allows chaining data and query operations on Data + * instances that may be resolved asynchronously. * - * This is used under the hood by {@link Data} and you most probably won't use it directly. + * Produced by {@link Data.create}; schema configuration lives on + * {@link DataSchema} and happens before the PromisedData is created. * * @template $ - The metadata type extending Data.Meta * @extends {Promise>} - * @implements {ConfigurableData<$>} * @implements {InsertableData<$>} * @implements {QueryableData<$>} */ export class PromisedData<$ extends DT.Meta> extends Promise> - implements ConfigurableData<$>, InsertableData<$>, QueryableData<$> { + implements InsertableData<$>, QueryableData<$> { /** * Creates a new PromisedData instance. * @@ -58,31 +53,13 @@ export class PromisedData<$ extends DT.Meta> extends Promise> /** * Internal helper method to create a new PromisedData instance by applying a transformation. * - * @template $$ - The new metadata type * @param cb - Callback function that transforms the Data instance * @returns A new PromisedData instance with the transformed Data */ - #create<$$ extends DT.Meta>( - cb: (data: Data<$>) => MaybePromise>, - ): PromisedData<$$> { + #create(cb: (data: Data<$>) => MaybePromise>): PromisedData<$> { return new PromisedData((resolve) => resolve(this.then(cb))) } - /** - * Registers additional payload parsers. - * - * @template Parsers - The Zod shape type for the parsers - * @param parsers - An object containing Zod parsers for payload validation - * @returns A new PromisedData instance with the updated payload parsers - */ - usePayload( - parsers: Parsers, - ): PromisedData< - DT.AssignPayloadParsers<$, Parsers> - > { - return this.#create((data) => data.usePayload(parsers)) - } - /** * Inserts data items into the dataset. * @@ -112,37 +89,6 @@ export class PromisedData<$ extends DT.Meta> extends Promise> return this.#create((data) => data.addRules(name, opts)) } - /** - * Registers targeting descriptors to enable query-based filtering. - * - * @template TDs - The targeting descriptor record type - * @param targeting - An object containing targeting descriptors - * @returns A new PromisedData instance with the updated targeting and query parsers - */ - useTargeting( - targeting: TDs, - ): PromisedData< - DT.AssignTargetingDescriptorRecord<$, TDs> - > { - return this.#create((data) => data.useTargeting(targeting)) - } - - /** - * Registers fall-through targeting descriptors. - * Fall-through targeting allows rules to continue matching after a successful match. - * - * @template TDs - The fall-through targeting descriptor record type - * @param targeting - An object containing fall-through targeting descriptors - * @returns A new PromisedData instance with the updated fall-through targeting parsers - */ - useFallThroughTargeting( - targeting: TDs, - ): PromisedData< - DT.AssignFallThroughTargetingParsers<$, FTTT.ParsersRecord> - > { - return this.#create((data) => data.useFallThroughTargeting(targeting)) - } - /** * Retrieves payloads for all registered payload names, optionally filtered by a query. * diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index edc468cff..833d3cca2 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,5 +1,7 @@ export { default as Data } from './Data.ts' export * from './PromisedData.ts' +export { DataSchema } from './DataSchema.ts' +export type { BuiltDataSchema } from './DataSchema.ts' export { DataItemParser } from './parsers/DataItem.ts' export { DataItemsParser } from './parsers/DataItems.ts' export { DataItemRuleParser } from './parsers/DataItemRule.ts' @@ -11,7 +13,6 @@ export type { default as TargetingPredicate } from './parsers/TargetingPredicate export type { default as TargetingPredicates } from './parsers/TargetingPredicates.ts' export { targetEquals } from './predicates/equals.ts' export { targetIncludes } from './predicates/targetIncludes.ts' -export type { ConfigurableData } from './ConfigurableData.ts' export type { InsertableData } from './InsertableData.ts' export type { QueryableData } from './QueryableData.ts' export type { StaticRecord } from './types.ts' diff --git a/packages/api/src/predicates/equals.ts b/packages/api/src/predicates/equals.ts index 6219f3f8c..3fe6aca08 100644 --- a/packages/api/src/predicates/equals.ts +++ b/packages/api/src/predicates/equals.ts @@ -11,26 +11,26 @@ import type { $ZodType } from 'zod/v4/core' * * @example * ```ts - * import { Data, targetEquals } from '@targetd/api' + * import { Data, DataSchema, targetEquals } from '@targetd/api' * import { z } from 'zod' * - * const data = await Data.create() - * .usePayload({ feature: z.string() }) - * .useTargeting({ isPremium: targetEquals(z.boolean()) }) - * .addRules('feature', [ - * { targeting: { isPremium: true }, payload: 'Premium feature' }, - * { payload: 'Basic feature' } - * ]) + * const data = await Data.create( + * DataSchema.create() + * .usePayload({ feature: z.string() }) + * .useTargeting({ isPremium: targetEquals(z.boolean()) }) + * .build(), + * ).addRules('feature', [ + * { targeting: { isPremium: true }, payload: 'Premium feature' }, + * { payload: 'Basic feature' } + * ]) * * await data.getPayload('feature', { isPremium: true }) // 'Premium feature' * ``` * * @example With negation: * ```ts - * .useTargeting({ environment: targetEquals(z.string(), { withNegate: true }) }) - * .addRules('config', [ - * { targeting: { environment: '!production' }, payload: 'Debug mode' } - * ]) + * DataSchema.create() + * .useTargeting({ environment: targetEquals(z.string(), { withNegate: true }) }) * ``` */ export function targetEquals( diff --git a/packages/api/src/predicates/targetIncludes.ts b/packages/api/src/predicates/targetIncludes.ts index a92e3b3f8..269f6be6b 100644 --- a/packages/api/src/predicates/targetIncludes.ts +++ b/packages/api/src/predicates/targetIncludes.ts @@ -13,17 +13,19 @@ import { array } from 'zod/mini' * * @example * ```ts - * import { Data, targetIncludes } from '@targetd/api' + * import { Data, DataSchema, targetIncludes } from '@targetd/api' * import { z } from 'zod' * - * const data = await Data.create() - * .usePayload({ content: z.string() }) - * .useTargeting({ country: targetIncludes(z.string()) }) - * .addRules('content', [ - * { targeting: { country: ['US', 'CA'] }, payload: 'North America content' }, - * { targeting: { country: ['UK', 'FR'] }, payload: 'Europe content' }, - * { payload: 'Default content' } - * ]) + * const data = await Data.create( + * DataSchema.create() + * .usePayload({ content: z.string() }) + * .useTargeting({ country: targetIncludes(z.string()) }) + * .build(), + * ).addRules('content', [ + * { targeting: { country: ['US', 'CA'] }, payload: 'North America content' }, + * { targeting: { country: ['UK', 'FR'] }, payload: 'Europe content' }, + * { payload: 'Default content' } + * ]) * * await data.getPayload('content', { country: 'US' }) // 'North America content' * await data.getPayload('content', { country: 'UK' }) // 'Europe content' @@ -31,10 +33,8 @@ import { array } from 'zod/mini' * * @example With negation: * ```ts - * .useTargeting({ platform: targetIncludes(z.string(), { withNegate: true }) }) - * .addRules('content', [ - * { targeting: { platform: ['!mobile'] }, payload: 'Desktop-only content' } - * ]) + * DataSchema.create() + * .useTargeting({ platform: targetIncludes(z.string(), { withNegate: true }) }) * ``` */ export function targetIncludes( diff --git a/packages/api/src/types/Data.ts b/packages/api/src/types/Data.ts index 2d2715cbe..851c3f053 100644 --- a/packages/api/src/types/Data.ts +++ b/packages/api/src/types/Data.ts @@ -1,7 +1,4 @@ -import type { Assign } from 'utility-types' import type Data from '../Data.ts' -import type * as QT from './Query.ts' -import type * as TT from './Targeting.ts' import type * as FTTT from './FallThroughTargeting.ts' import type { $ZodShape, output } from 'zod/v4/core' @@ -26,48 +23,6 @@ export interface EmptyMeta { FallThroughTargetingParsers: {} } -export type AssignPayloadParsers<$ extends Meta, Parsers extends $ZodShape> = { - PayloadParsers: Assign<$['PayloadParsers'], Parsers> - TargetingParsers: $['TargetingParsers'] - QueryParsers: $['QueryParsers'] - FallThroughTargetingParsers: $['FallThroughTargetingParsers'] -} - -export type AssignTargetingParsers<$ extends Meta, Parsers extends $ZodShape> = - { - PayloadParsers: $['PayloadParsers'] - TargetingParsers: Assign<$['TargetingParsers'], Parsers> - QueryParsers: $['QueryParsers'] - FallThroughTargetingParsers: $['FallThroughTargetingParsers'] - } - -export type AssignQueryParsers<$ extends Meta, Parsers extends $ZodShape> = { - PayloadParsers: $['PayloadParsers'] - TargetingParsers: $['TargetingParsers'] - QueryParsers: Assign<$['QueryParsers'], Parsers> - FallThroughTargetingParsers: $['FallThroughTargetingParsers'] -} - -export type AssignFallThroughTargetingParsers< - $ extends Meta, - Parsers extends $ZodShape, -> = { - PayloadParsers: $['PayloadParsers'] - TargetingParsers: $['TargetingParsers'] - QueryParsers: $['QueryParsers'] - FallThroughTargetingParsers: Assign<$['FallThroughTargetingParsers'], Parsers> -} - -export type AssignTargetingDescriptorRecord< - $ extends Meta, - TDs extends TT.DescriptorRecord, -> = { - PayloadParsers: $['PayloadParsers'] - TargetingParsers: Assign<$['TargetingParsers'], TT.ParserRecord> - QueryParsers: Assign<$['QueryParsers'], QT.ParserRecord> - FallThroughTargetingParsers: $['FallThroughTargetingParsers'] -} - /** * Match any Data */ diff --git a/packages/api/test/Data.test.ts b/packages/api/test/Data.test.ts index f16e78374..575f7df2a 100644 --- a/packages/api/test/Data.test.ts +++ b/packages/api/test/Data.test.ts @@ -9,23 +9,27 @@ import z, { type ZodError } from 'zod' import { createTargetingDescriptor, Data, + DataSchema, targetEquals, targetIncludes, } from '@targetd/api' Deno.test('getPayload', async () => { - const data = await Data.create() - .usePayload({ 'foo': z.string() }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetEquals(z.boolean()), - asyncThing: { - predicate: (q) => - setTimeout(10, (t: boolean) => q === t && setTimeout(10, true)), - queryParser: z.boolean(), - targetingParser: z.boolean(), - }, - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ 'foo': z.string() }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetEquals(z.boolean()), + asyncThing: { + predicate: (q) => + setTimeout(10, (t: boolean) => q === t && setTimeout(10, true)), + queryParser: z.boolean(), + targetingParser: z.boolean(), + }, + }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -98,12 +102,15 @@ Deno.test('getPayload', async () => { }) Deno.test('targeting with multiple conditions', async () => { - const data = await Data.create() - .usePayload({ foo: z.string() }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetEquals(z.boolean()), - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ foo: z.string() }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetEquals(z.boolean()), + }) + .build(), + ) .addRules('foo', [ { targeting: [ @@ -133,18 +140,21 @@ Deno.test('targeting with multiple conditions', async () => { }) Deno.test('targeting without requiring a query', async () => { - const data = await Data.create() - .usePayload({ - foo: z.string(), - }) - .useTargeting({ - time: { - predicate: () => (t) => t === 'now!', - queryParser: z.undefined(), - requiresQuery: false, - targetingParser: z.literal('now!'), - }, - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + }) + .useTargeting({ + time: { + predicate: () => (t) => t === 'now!', + queryParser: z.undefined(), + requiresQuery: false, + targetingParser: z.literal('now!'), + }, + }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -161,14 +171,17 @@ Deno.test('targeting without requiring a query', async () => { }) Deno.test('getPayloads', async (t) => { - const data = await Data.create() - .usePayload({ - foo: z.string(), - }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetEquals(z.boolean()), - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetEquals(z.boolean()), + }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -198,10 +211,13 @@ Deno.test('getPayloads', async (t) => { Deno.test('payload runtype validation', async (t) => { try { - await Data.create() - .usePayload({ - foo: z.string().refine((x) => x === 'bar', 'Should be bar'), - }) + await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string().refine((x) => x === 'bar', 'Should be bar'), + }) + .build(), + ) .addRules('foo', [ { payload: 'rab', @@ -216,21 +232,24 @@ Deno.test('payload runtype validation', async (t) => { }) Deno.test('getPayloadForEachName', async (t) => { - const data = await Data.create() - .usePayload({ - foo: z.string(), - bar: z.string(), - }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetIncludes(z.boolean()), - asyncThing: { - predicate: (q) => - setTimeout(10, (t: boolean) => q === t && setTimeout(10, true)), - queryParser: z.boolean(), - targetingParser: z.boolean(), - }, - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + bar: z.string(), + }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetIncludes(z.boolean()), + asyncThing: { + predicate: (q) => + setTimeout(10, (t: boolean) => q === t && setTimeout(10, true)), + queryParser: z.boolean(), + targetingParser: z.boolean(), + }, + }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -277,14 +296,17 @@ Deno.test('getPayloadForEachName', async (t) => { }) Deno.test('fallThrough targeting', async (t) => { - const data = await Data.create() - .usePayload({ - foo: z.string(), - bar: z.string(), - mung: z.string(), - }) - .useTargeting({ surf: targetIncludes(z.string(), { withNegate: true }) }) - .useFallThroughTargeting({ weather: z.array(z.string()) }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + bar: z.string(), + mung: z.string(), + }) + .useTargeting({ surf: targetIncludes(z.string(), { withNegate: true }) }) + .useFallThroughTargeting({ weather: z.array(z.string()) }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -346,18 +368,21 @@ Deno.test('fallThrough targeting', async (t) => { }) Deno.test('inserting data', async (t) => { - const data = await Data.create() - .usePayload({ - moo: z.string(), - foo: z.string(), - bar: z.string(), - }) - .useTargeting({ - weather: targetIncludes(z.string()), - }) - .useFallThroughTargeting({ - highTide: targetEquals(z.boolean()), - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + moo: z.string(), + foo: z.string(), + bar: z.string(), + }) + .useTargeting({ + weather: targetIncludes(z.string()), + }) + .useFallThroughTargeting({ + highTide: targetEquals(z.boolean()), + }) + .build(), + ) .insert({ bar: { __rules__: [ @@ -401,16 +426,19 @@ Deno.test('inserting data', async (t) => { }) Deno.test('inserting data with variables', async (t) => { - const data = await Data.create() - .usePayload({ - moo: z.string(), - foo: z.string(), - bar: z.string(), - }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetEquals(z.boolean()), - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + moo: z.string(), + foo: z.string(), + bar: z.string(), + }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetEquals(z.boolean()), + }) + .build(), + ) .insert({ bar: { __variables__: { @@ -468,23 +496,26 @@ Deno.test('targeting predicate with full query object', async () => { targeting.includes(queryValue), }) - const data = await Data.create() - .usePayload({ - foo: z.string(), - }) - .useTargeting({ - oof: { - queryParser: z.string(), - targetingParser: z.string(), - predicate: (q) => (t) => q === t, - }, - bar: { - queryParser: z.boolean(), - targetingParser: z.boolean(), - predicate: (q) => (t) => q === t, - }, - mung: mungTargeting, - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + }) + .useTargeting({ + oof: { + queryParser: z.string(), + targetingParser: z.string(), + predicate: (q) => (t) => q === t, + }, + bar: { + queryParser: z.boolean(), + targetingParser: z.boolean(), + predicate: (q) => (t) => q === t, + }, + mung: mungTargeting, + }) + .build(), + ) .addRules('foo', [ { targeting: { mung: ['mung'] }, @@ -509,14 +540,17 @@ Deno.test('broken', async (t) => { foo: z.string(), } - const data = await Data.create() - .usePayload(payloadSchema) - .useTargeting({ - channel: channelTargeting, - }) - .useFallThroughTargeting({ - browser: browserTargeting, - }) + const data = await Data.create( + DataSchema.create() + .usePayload(payloadSchema) + .useTargeting({ + channel: channelTargeting, + }) + .useFallThroughTargeting({ + browser: browserTargeting, + }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -545,23 +579,26 @@ Deno.test('broken', async (t) => { }) Deno.test('variables', async (t) => { - const data = await Data.create() - .usePayload({ - foo: z.strictObject({ - a: z.strictObject({ - b: z.strictObject({ - c: z.string(), - d: z.number(), + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.strictObject({ + a: z.strictObject({ + b: z.strictObject({ + c: z.string(), + d: z.number(), + }), }), }), - }), - }) - .useTargeting({ - channel: targetIncludes(z.enum(['foo', 'bar'])), - }) - .useFallThroughTargeting({ - browser: targetIncludes(z.enum(['chrome', 'edge'])), - }) + }) + .useTargeting({ + channel: targetIncludes(z.enum(['foo', 'bar'])), + }) + .useFallThroughTargeting({ + browser: targetIncludes(z.enum(['chrome', 'edge'])), + }) + .build(), + ) .addRules('foo', { variables: { c: [ @@ -599,23 +636,26 @@ Deno.test('variables', async (t) => { }) Deno.test('variables using fallthrough targeting', async (t) => { - const data = await Data.create() - .usePayload({ - foo: z.strictObject({ - a: z.strictObject({ - b: z.strictObject({ - c: z.string(), - d: z.number(), + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.strictObject({ + a: z.strictObject({ + b: z.strictObject({ + c: z.string(), + d: z.number(), + }), }), }), - }), - }) - .useTargeting({ - channel: targetIncludes(z.enum(['foo', 'bar'])), - }) - .useFallThroughTargeting({ - browser: targetIncludes(z.enum(['chrome', 'edge'])), - }) + }) + .useTargeting({ + channel: targetIncludes(z.enum(['foo', 'bar'])), + }) + .useFallThroughTargeting({ + browser: targetIncludes(z.enum(['chrome', 'edge'])), + }) + .build(), + ) .addRules('foo', { variables: { c: [ @@ -654,10 +694,13 @@ Deno.test('variables using fallthrough targeting', async (t) => { }) Deno.test('variables in records', async () => { - const data = await Data.create() - .usePayload({ - foo: z.record(z.string(), z.array(z.number())), - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.record(z.string(), z.array(z.number())), + }) + .build(), + ) .addRules('foo', { variables: { a: [{ payload: [1, 2, 3] }], @@ -670,25 +713,28 @@ Deno.test('variables in records', async () => { }) 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}}' }] }], - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.array(z.number()), + bar: z.array(z.strictObject({ + b: z.number(), + c: z.string(), + })), + }) + .build(), + ).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, @@ -707,16 +753,19 @@ Deno.test('variables in arrays', async (t) => { }) Deno.test('errors when using variables with incorrect types', async (t) => { - const data = await Data.create() - .usePayload({ - foo: z.strictObject({ - a: z.strictObject({ - b: z.strictObject({ - c: z.string(), + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.strictObject({ + a: z.strictObject({ + b: z.strictObject({ + c: z.string(), + }), }), }), - }), - }) + }) + .build(), + ) await assertSnapshot( t, @@ -754,27 +803,30 @@ Deno.test( throw new Error('This should never get called') }, }) - const baseSchema = Data.create() + const basePayload = DataSchema.create() .usePayload({ 'foo': z.string() }) - const clientSchema = baseSchema + const clientConfig = basePayload .useTargeting({ fft: minInnerWindowWidthTargeting }) - const serverSchema = baseSchema - .useFallThroughTargeting((await clientSchema).targetingParsers) - .addRules('foo', { - variables: { - x: [{ - targeting: { - fft: 129, - }, - payload: 'fft', - }, { - payload: 'st', - }], - }, - rules: [ - { payload: '{{x}}' }, - ], - }) + .build() + const serverSchema = Data.create( + basePayload + .useFallThroughTargeting(clientConfig.targetingParsers) + .build(), + ).addRules('foo', { + variables: { + x: [{ + targeting: { + fft: 129, + }, + payload: 'fft', + }, { + payload: 'st', + }], + }, + rules: [ + { payload: '{{x}}' }, + ], + }) await serverSchema.getPayloadForEachName() }, ) diff --git a/packages/api/test/type-stress.test.ts b/packages/api/test/type-stress.test.ts new file mode 100644 index 000000000..ae563af40 --- /dev/null +++ b/packages/api/test/type-stress.test.ts @@ -0,0 +1,149 @@ +import { assertStrictEquals } from 'jsr:@std/assert' +import z from 'zod' +import { Data, DataSchema, targetEquals } from '@targetd/api' + +Deno.test('type stress: 100 payloads + 30 targeting descriptors', async () => { + const data = await Data.create( + DataSchema.create() + .usePayload({ payload_0: z.string() }) + .usePayload({ payload_1: z.string() }) + .usePayload({ payload_2: z.string() }) + .usePayload({ payload_3: z.string() }) + .usePayload({ payload_4: z.string() }) + .usePayload({ payload_5: z.string() }) + .usePayload({ payload_6: z.string() }) + .usePayload({ payload_7: z.string() }) + .usePayload({ payload_8: z.string() }) + .usePayload({ payload_9: z.string() }) + .usePayload({ payload_10: z.string() }) + .usePayload({ payload_11: z.string() }) + .usePayload({ payload_12: z.string() }) + .usePayload({ payload_13: z.string() }) + .usePayload({ payload_14: z.string() }) + .usePayload({ payload_15: z.string() }) + .usePayload({ payload_16: z.string() }) + .usePayload({ payload_17: z.string() }) + .usePayload({ payload_18: z.string() }) + .usePayload({ payload_19: z.string() }) + .usePayload({ payload_20: z.string() }) + .usePayload({ payload_21: z.string() }) + .usePayload({ payload_22: z.string() }) + .usePayload({ payload_23: z.string() }) + .usePayload({ payload_24: z.string() }) + .usePayload({ payload_25: z.string() }) + .usePayload({ payload_26: z.string() }) + .usePayload({ payload_27: z.string() }) + .usePayload({ payload_28: z.string() }) + .usePayload({ payload_29: z.string() }) + .usePayload({ payload_30: z.string() }) + .usePayload({ payload_31: z.string() }) + .usePayload({ payload_32: z.string() }) + .usePayload({ payload_33: z.string() }) + .usePayload({ payload_34: z.string() }) + .usePayload({ payload_35: z.string() }) + .usePayload({ payload_36: z.string() }) + .usePayload({ payload_37: z.string() }) + .usePayload({ payload_38: z.string() }) + .usePayload({ payload_39: z.string() }) + .usePayload({ payload_40: z.string() }) + .usePayload({ payload_41: z.string() }) + .usePayload({ payload_42: z.string() }) + .usePayload({ payload_43: z.string() }) + .usePayload({ payload_44: z.string() }) + .usePayload({ payload_45: z.string() }) + .usePayload({ payload_46: z.string() }) + .usePayload({ payload_47: z.string() }) + .usePayload({ payload_48: z.string() }) + .usePayload({ payload_49: z.string() }) + .usePayload({ payload_50: z.string() }) + .usePayload({ payload_51: z.string() }) + .usePayload({ payload_52: z.string() }) + .usePayload({ payload_53: z.string() }) + .usePayload({ payload_54: z.string() }) + .usePayload({ payload_55: z.string() }) + .usePayload({ payload_56: z.string() }) + .usePayload({ payload_57: z.string() }) + .usePayload({ payload_58: z.string() }) + .usePayload({ payload_59: z.string() }) + .usePayload({ payload_60: z.string() }) + .usePayload({ payload_61: z.string() }) + .usePayload({ payload_62: z.string() }) + .usePayload({ payload_63: z.string() }) + .usePayload({ payload_64: z.string() }) + .usePayload({ payload_65: z.string() }) + .usePayload({ payload_66: z.string() }) + .usePayload({ payload_67: z.string() }) + .usePayload({ payload_68: z.string() }) + .usePayload({ payload_69: z.string() }) + .usePayload({ payload_70: z.string() }) + .usePayload({ payload_71: z.string() }) + .usePayload({ payload_72: z.string() }) + .usePayload({ payload_73: z.string() }) + .usePayload({ payload_74: z.string() }) + .usePayload({ payload_75: z.string() }) + .usePayload({ payload_76: z.string() }) + .usePayload({ payload_77: z.string() }) + .usePayload({ payload_78: z.string() }) + .usePayload({ payload_79: z.string() }) + .usePayload({ payload_80: z.string() }) + .usePayload({ payload_81: z.string() }) + .usePayload({ payload_82: z.string() }) + .usePayload({ payload_83: z.string() }) + .usePayload({ payload_84: z.string() }) + .usePayload({ payload_85: z.string() }) + .usePayload({ payload_86: z.string() }) + .usePayload({ payload_87: z.string() }) + .usePayload({ payload_88: z.string() }) + .usePayload({ payload_89: z.string() }) + .usePayload({ payload_90: z.string() }) + .usePayload({ payload_91: z.string() }) + .usePayload({ payload_92: z.string() }) + .usePayload({ payload_93: z.string() }) + .usePayload({ payload_94: z.string() }) + .usePayload({ payload_95: z.string() }) + .usePayload({ payload_96: z.string() }) + .usePayload({ payload_97: z.string() }) + .usePayload({ payload_98: z.string() }) + .usePayload({ payload_99: z.string() }) + .useTargeting({ target_0: targetEquals(z.string()) }) + .useTargeting({ target_1: targetEquals(z.string()) }) + .useTargeting({ target_2: targetEquals(z.string()) }) + .useTargeting({ target_3: targetEquals(z.string()) }) + .useTargeting({ target_4: targetEquals(z.string()) }) + .useTargeting({ target_5: targetEquals(z.string()) }) + .useTargeting({ target_6: targetEquals(z.string()) }) + .useTargeting({ target_7: targetEquals(z.string()) }) + .useTargeting({ target_8: targetEquals(z.string()) }) + .useTargeting({ target_9: targetEquals(z.string()) }) + .useTargeting({ target_10: targetEquals(z.string()) }) + .useTargeting({ target_11: targetEquals(z.string()) }) + .useTargeting({ target_12: targetEquals(z.string()) }) + .useTargeting({ target_13: targetEquals(z.string()) }) + .useTargeting({ target_14: targetEquals(z.string()) }) + .useTargeting({ target_15: targetEquals(z.string()) }) + .useTargeting({ target_16: targetEquals(z.string()) }) + .useTargeting({ target_17: targetEquals(z.string()) }) + .useTargeting({ target_18: targetEquals(z.string()) }) + .useTargeting({ target_19: targetEquals(z.string()) }) + .useTargeting({ target_20: targetEquals(z.string()) }) + .useTargeting({ target_21: targetEquals(z.string()) }) + .useTargeting({ target_22: targetEquals(z.string()) }) + .useTargeting({ target_23: targetEquals(z.string()) }) + .useTargeting({ target_24: targetEquals(z.string()) }) + .useTargeting({ target_25: targetEquals(z.string()) }) + .useTargeting({ target_26: targetEquals(z.string()) }) + .useTargeting({ target_27: targetEquals(z.string()) }) + .useTargeting({ target_28: targetEquals(z.string()) }) + .useTargeting({ target_29: targetEquals(z.string()) }) + .build(), + ) + .addRules('payload_0', [{ payload: 'hello' }]) + .addRules('payload_99', [{ payload: 'world' }]) + + assertStrictEquals(await data.getPayload('payload_0'), 'hello') + assertStrictEquals(await data.getPayload('payload_99'), 'world') + assertStrictEquals(await data.getPayload('payload_50'), undefined) + + const query = { target_0: 'a', target_15: 'b', target_29: 'c' } + assertStrictEquals(await data.getPayload('payload_0', query), 'hello') +}) diff --git a/packages/client/README.md b/packages/client/README.md index 0ab955940..1dcc891ef 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -28,16 +28,16 @@ Key features: ## Basic Usage -### 1. Define Shared Types +### 1. Define a Shared Schema -Create shared type definitions that both client and server will use: +Create a `DataSchema` that both client and server will use: ```typescript -// shared/types.ts -import { Data, targetIncludes } from '@targetd/api' +// shared/schema.ts +import { DataSchema, targetIncludes } from '@targetd/api' import { z } from 'zod' -export const data = await Data.create() +export const schema = DataSchema.create() .usePayload({ greeting: z.string(), config: z.object({ @@ -47,6 +47,7 @@ export const data = await Data.create() .useTargeting({ country: targetIncludes(z.string()), }) + .build() ``` ### 2. Set Up the Server @@ -56,10 +57,11 @@ endpoint: ```typescript // server.ts +import { Data } from '@targetd/api' import { createServer } from '@targetd/server' -import { data } from './shared/types.ts' +import { schema } from './shared/schema.ts' -const serverData = await data +const serverData = await Data.create(schema) .addRules('greeting', [ { targeting: { country: ['US'] }, @@ -84,13 +86,15 @@ createServer(serverData).listen(3000) ### 3. Query from the Client -Use the Client class with the same type definition: +Use the Client class with a local Data built from the same schema: ```typescript // client.ts +import { Data } from '@targetd/api' import { Client } from '@targetd/client' -import { data } from './shared/types.ts' +import { schema } from './shared/schema.ts' +const data = await Data.create(schema) const client = new Client('http://localhost:3000', data) // Type-safe queries with full autocomplete @@ -152,12 +156,12 @@ export const deviceTargeting = createTargetingDescriptor({ ``` ```typescript -// data.ts - Shared data structure -import { Data } from '@targetd/api' +// schema.ts - Shared schema +import { DataSchema } from '@targetd/api' import { z } from 'zod' import { deviceTargeting } from './device.ts' -export const data = await Data.create() +export const schema = DataSchema.create() .usePayload({ content: z.string(), maxItems: z.number(), @@ -165,14 +169,16 @@ export const data = await Data.create() .useTargeting({ device: deviceTargeting, }) + .build() ``` ```typescript // server.ts - Server implementation +import { Data } from '@targetd/api' import { createServer } from '@targetd/server' -import { data } from './data.ts' +import { schema } from './schema.ts' -const serverData = await data +const serverData = await Data.create(schema) .addRules('content', [ { targeting: { device: ['mobile'] }, @@ -199,9 +205,11 @@ createServer(serverData).listen(3000) ```typescript // client.ts - Client usage +import { Data } from '@targetd/api' import { Client } from '@targetd/client' -import { data } from './data.ts' +import { schema } from './schema.ts' +const data = await Data.create(schema) const client = new Client('http://localhost:3000', data) // Query for mobile diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8cebe45df..edc27293b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -214,7 +214,7 @@ export class Client<$ extends DT.Meta> implements QueryableData<$> { * * @example * ```ts - * const data = await Data.create()... + * const data = await Data.create(DataSchema.create()...build()) * type MyClient = ClientWithData * ``` */ diff --git a/packages/client/test/index.test.ts b/packages/client/test/index.test.ts index 4a8ca99fd..4dd3b09d9 100644 --- a/packages/client/test/index.test.ts +++ b/packages/client/test/index.test.ts @@ -1,6 +1,6 @@ import { assertStrictEquals } from 'jsr:@std/assert' import { assertSnapshot } from 'jsr:@std/testing/snapshot' -import { Data, targetEquals, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetEquals, targetIncludes } from '@targetd/api' import dateRangeTargeting from '@targetd/date-range' import { createServer } from '@targetd/server' import type { AddressInfo } from 'node:net' @@ -9,22 +9,26 @@ import z from 'zod' import { Client, type ClientWithData } from '@targetd/client' import { promisify } from 'node:util' -const data = await Data.create() - .usePayload({ - foo: z.string(), - bar: z.number(), - timed: z.string(), - }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetEquals(z.boolean()), - asyncThing: { - predicate: (q) => setTimeout(10, (t) => q === t && setTimeout(10, true)), - queryParser: z.boolean(), - targetingParser: z.boolean(), - }, - date: dateRangeTargeting, - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + bar: z.number(), + timed: z.string(), + }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetEquals(z.boolean()), + asyncThing: { + predicate: (q) => + setTimeout(10, (t) => q === t && setTimeout(10, true)), + queryParser: z.boolean(), + targetingParser: z.boolean(), + }, + date: dateRangeTargeting, + }) + .build(), +) .addRules('foo', [ { targeting: { diff --git a/packages/date-range/README.md b/packages/date-range/README.md index a5bb854af..6eda17572 100644 --- a/packages/date-range/README.md +++ b/packages/date-range/README.md @@ -30,39 +30,41 @@ provided, making it perfect for real-time targeting. ## Basic Usage ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import dateRangeTargeting from '@targetd/date-range' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ banner: z.string(), }) .useTargeting({ dateRange: dateRangeTargeting, }) - .addRules('banner', [ - { - targeting: { - dateRange: { - start: '2024-12-01', - end: '2024-12-31', - }, + .build() + +const data = await Data.create(schema).addRules('banner', [ + { + targeting: { + dateRange: { + start: '2024-12-01', + end: '2024-12-31', }, - payload: '🎄 Holiday Sale!', }, - { - targeting: { - dateRange: { - start: '2025-01-01', - }, + payload: '🎄 Holiday Sale!', + }, + { + targeting: { + dateRange: { + start: '2025-01-01', }, - payload: '🎉 New Year Special!', }, - { - payload: 'Regular banner', - }, - ]) + payload: '🎉 New Year Special!', + }, + { + payload: 'Regular banner', + }, +]) // Query with current system time const currentBanner = await data.getPayload('banner') @@ -194,11 +196,11 @@ Target multiple date ranges with an array: Here's a comprehensive example showing event-based content delivery: ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import dateRangeTargeting from '@targetd/date-range' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ event: z.object({ name: z.string(), @@ -209,40 +211,42 @@ const data = await Data.create() .useTargeting({ dateRange: dateRangeTargeting, }) - .addRules('event', [ - { - targeting: { - dateRange: { - start: '1939-09-01', - end: '1945-09-02', - }, - }, - payload: { - name: 'World War II', - message: 'Historical period: 1939-1945', - active: true, + .build() + +const data = await Data.create(schema).addRules('event', [ + { + targeting: { + dateRange: { + start: '1939-09-01', + end: '1945-09-02', }, }, - { - targeting: { - dateRange: { - start: '2020-01-01T00:00:00', - }, - }, - payload: { - name: 'Modern Era', - message: 'Welcome to the 2020s', - active: true, - }, + payload: { + name: 'World War II', + message: 'Historical period: 1939-1945', + active: true, }, - { - payload: { - name: 'No Active Event', - message: 'No special event during this period', - active: false, + }, + { + targeting: { + dateRange: { + start: '2020-01-01T00:00:00', }, }, - ]) + payload: { + name: 'Modern Era', + message: 'Welcome to the 2020s', + active: true, + }, + }, + { + payload: { + name: 'No Active Event', + message: 'No special event during this period', + active: false, + }, + }, +]) // Query with current time const currentEvent = await data.getPayload('event') diff --git a/packages/date-range/src/index.ts b/packages/date-range/src/index.ts index 20014fc88..8cb8a7a21 100644 --- a/packages/date-range/src/index.ts +++ b/packages/date-range/src/index.ts @@ -53,22 +53,24 @@ const targetingParser: TargetingParser = union([ * * @example * ```ts - * import { Data } from '@targetd/api' + * import { Data, DataSchema } from '@targetd/api' * import dateRangeTargeting from '@targetd/date-range' * import { z } from 'zod' * - * const data = await Data.create() - * .usePayload({ campaign: z.string() }) - * .useTargeting({ date: dateRangeTargeting }) - * .addRules('campaign', [ - * { - * targeting: { - * date: { start: '2024-12-01', end: '2024-12-31' } - * }, - * payload: 'Holiday Campaign' + * const data = await Data.create( + * DataSchema.create() + * .usePayload({ campaign: z.string() }) + * .useTargeting({ date: dateRangeTargeting }) + * .build(), + * ).addRules('campaign', [ + * { + * targeting: { + * date: { start: '2024-12-01', end: '2024-12-31' } * }, - * { payload: 'Regular Campaign' } - * ]) + * payload: 'Holiday Campaign' + * }, + * { payload: 'Regular Campaign' } + * ]) * * // Automatic current date evaluation * await data.getPayload('campaign') diff --git a/packages/date-range/test/index.test.ts b/packages/date-range/test/index.test.ts index d35745a95..c69ee8bd6 100644 --- a/packages/date-range/test/index.test.ts +++ b/packages/date-range/test/index.test.ts @@ -1,17 +1,20 @@ import { assertStrictEquals } from 'jsr:@std/assert' import { FakeTime } from 'jsr:@std/testing/time' -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import z from 'zod' import dateRangeTargeting from '@targetd/date-range' Deno.test('date range predicate', async () => { - const data = await Data.create() - .usePayload({ - foo: z.string(), - }) - .useTargeting({ - dateRange: dateRangeTargeting, - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + }) + .useTargeting({ + dateRange: dateRangeTargeting, + }) + .build(), + ) .addRules('foo', [ { targeting: { diff --git a/packages/explode/README.md b/packages/explode/README.md index e0a1349fd..694135f94 100644 --- a/packages/explode/README.md +++ b/packages/explode/README.md @@ -29,16 +29,19 @@ This is useful for: ## Basic Usage ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import { explode } from '@targetd/explode' import { z } from 'zod' -const data = await Data.create() - .usePayload({ - 'app.title': z.string(), - 'app.version': z.string(), - 'feature.darkMode': z.boolean(), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + 'app.title': z.string(), + 'app.version': z.string(), + 'feature.darkMode': z.boolean(), + }) + .build(), +) .addRules('app.title', [{ payload: 'My App' }]) .addRules('app.version', [{ payload: '1.0.0' }]) .addRules('feature.darkMode', [{ payload: true }]) @@ -82,17 +85,20 @@ const nested = explode(flatObject, '.') Organize application settings by namespace: ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import { explode } from '@targetd/explode' import { z } from 'zod' -const data = await Data.create() - .usePayload({ - 'api.endpoint': z.string(), - 'api.timeout': z.number(), - 'ui.theme': z.string(), - 'ui.language': z.string(), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + 'api.endpoint': z.string(), + 'api.timeout': z.number(), + 'ui.theme': z.string(), + 'ui.language': z.string(), + }) + .build(), +) .addRules('api.endpoint', [{ payload: 'https://api.example.com' }]) .addRules('api.timeout', [{ payload: 5000 }]) .addRules('ui.theme', [{ payload: 'dark' }]) @@ -116,11 +122,11 @@ const config = explode(await data.getPayloadForEachName(), '.') Structure feature flags with platform-specific configurations: ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { explode } from '@targetd/explode' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ 'feature.mobile.enabled': z.boolean(), 'feature.mobile.maxItems': z.number(), @@ -130,6 +136,9 @@ const data = await Data.create() .useTargeting({ platform: targetIncludes(z.string()), }) + .build() + +const data = await Data.create(schema) .addRules('feature.mobile.enabled', [ { targeting: { platform: ['ios', 'android'] }, diff --git a/packages/fs/README.md b/packages/fs/README.md index 3a674d05c..c28edc473 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -78,20 +78,23 @@ greeting: Use `load()` to read rules from a directory once: ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { load } from '@targetd/fs' import { z } from 'zod' -const data = await Data.create() - .usePayload({ - greeting: z.string(), - config: z.object({ - enabled: z.boolean(), - }), - }) - .useTargeting({ - country: targetIncludes(z.string()), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + greeting: z.string(), + config: z.object({ + enabled: z.boolean(), + }), + }) + .useTargeting({ + country: targetIncludes(z.string()), + }) + .build(), +) // Load rules from directory const dataWithRules = await load(data, './rules') @@ -105,17 +108,20 @@ const greeting = await dataWithRules.getPayload('greeting', { country: 'US' }) Use `watch()` to automatically reload rules when files change: ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { watch } from '@targetd/fs' import { z } from 'zod' -const data = await Data.create() - .usePayload({ - greeting: z.string(), - }) - .useTargeting({ - country: targetIncludes(z.string()), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + greeting: z.string(), + }) + .useTargeting({ + country: targetIncludes(z.string()), + }) + .build(), +) // Watch directory and reload on changes const stopWatching = watch( @@ -183,21 +189,24 @@ The `onLoad` callback receives: ### Basic Application Setup ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { load } from '@targetd/fs' import { z } from 'zod' import * as path from 'node:path' // Define data structure -const data = await Data.create() - .usePayload({ - 'app.title': z.string(), - 'app.version': z.string(), - 'feature.enabled': z.boolean(), - }) - .useTargeting({ - environment: targetIncludes(z.string()), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + 'app.title': z.string(), + 'app.version': z.string(), + 'feature.enabled': z.boolean(), + }) + .useTargeting({ + environment: targetIncludes(z.string()), + }) + .build(), +) // Load rules from filesystem const appData = await load( @@ -243,22 +252,25 @@ feature.enabled: ### Hot Reloading in Development ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { watch } from '@targetd/fs' import { z } from 'zod' let currentData: Data -const data = await Data.create() - .usePayload({ - feature: z.object({ - name: z.string(), - enabled: z.boolean(), - }), - }) - .useTargeting({ - environment: targetIncludes(z.string()), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + feature: z.object({ + name: z.string(), + enabled: z.boolean(), + }), + }) + .useTargeting({ + environment: targetIncludes(z.string()), + }) + .build(), +) // Watch for changes during development const stopWatching = watch( @@ -309,14 +321,17 @@ greeting: ``` ```typescript -const data = await Data.create() - .usePayload({ - greeting: z.string(), - }) - .useTargeting({ - country: targetIncludes(z.string()), - tier: targetIncludes(z.string()), - }) +const data = await Data.create( + DataSchema.create() + .usePayload({ + greeting: z.string(), + }) + .useTargeting({ + country: targetIncludes(z.string()), + tier: targetIncludes(z.string()), + }) + .build(), +) const dataWithRules = await load(data, './rules') diff --git a/packages/fs/src/load.ts b/packages/fs/src/load.ts index 6a6095f8c..a3963f8d1 100644 --- a/packages/fs/src/load.ts +++ b/packages/fs/src/load.ts @@ -25,13 +25,16 @@ type FileData = output * * @example * ```ts - * import { Data, targetIncludes } from '@targetd/api' + * import { Data, DataSchema, targetIncludes } from '@targetd/api' * import { load } from '@targetd/fs' * import { z } from 'zod' * - * const baseData = await Data.create() - * .usePayload({ greeting: z.string() }) - * .useTargeting({ country: targetIncludes(z.string()) }) + * const baseData = await Data.create( + * DataSchema.create() + * .usePayload({ greeting: z.string() }) + * .useTargeting({ country: targetIncludes(z.string()) }) + * .build(), + * ) * * const data = await load(baseData, './rules') * // Loads rules from ./rules/*.{json,yaml,yml} diff --git a/packages/fs/src/watch.ts b/packages/fs/src/watch.ts index 56328d554..6ae5ef570 100644 --- a/packages/fs/src/watch.ts +++ b/packages/fs/src/watch.ts @@ -52,13 +52,16 @@ export interface WatchOptions extends BaseWatchOptions { * * @example * ```ts - * import { Data, targetIncludes } from '@targetd/api' + * import { Data, DataSchema, targetIncludes } from '@targetd/api' * import { watch } from '@targetd/fs' * import { z } from 'zod' * - * const baseData = await Data.create() - * .usePayload({ greeting: z.string() }) - * .useTargeting({ country: targetIncludes(z.string()) }) + * const baseData = await Data.create( + * DataSchema.create() + * .usePayload({ greeting: z.string() }) + * .useTargeting({ country: targetIncludes(z.string()) }) + * .build(), + * ) * * let currentData = baseData * diff --git a/packages/fs/test/fixtures/data.ts b/packages/fs/test/fixtures/data.ts index 5004633ff..23dfaeb10 100644 --- a/packages/fs/test/fixtures/data.ts +++ b/packages/fs/test/fixtures/data.ts @@ -1,9 +1,12 @@ -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { string, z } from 'zod' -export const data = await Data.create() - .usePayload({ - foo: z.string(), - b: z.string(), - }) - .useTargeting({ channel: targetIncludes(string()) }) +export const data = await Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + b: z.string(), + }) + .useTargeting({ channel: targetIncludes(string()) }) + .build(), +) diff --git a/packages/json-schema/test/fixtures/data.ts b/packages/json-schema/test/fixtures/data.ts index b4bd7946c..eb3f74106 100644 --- a/packages/json-schema/test/fixtures/data.ts +++ b/packages/json-schema/test/fixtures/data.ts @@ -1,24 +1,27 @@ -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { z } from 'zod/mini' -export const data = await Data.create() - .usePayload( - { - foo: z.string(), - bar: z.strictObject({ - a: z.number(), - b: z.array(z.string()), - }), - record: z.record(z.string(), z.number()), - array: z.array(z.number()), - }, - ) - .useTargeting({ - weather: targetIncludes(z.string()), - }) - .useFallThroughTargeting({ - browser: targetIncludes(z.string()), - }) +export const data = await Data.create( + DataSchema.create() + .usePayload( + { + foo: z.string(), + bar: z.strictObject({ + a: z.number(), + b: z.array(z.string()), + }), + record: z.record(z.string(), z.number()), + array: z.array(z.number()), + }, + ) + .useTargeting({ + weather: targetIncludes(z.string()), + }) + .useFallThroughTargeting({ + browser: targetIncludes(z.string()), + }) + .build(), +) .addRules('foo', { variables: { weatherMessage: [ diff --git a/packages/server/README.md b/packages/server/README.md index 568efcd0a..0a8e0b50e 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -30,11 +30,11 @@ Key features: ## Basic Usage ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { createServer } from '@targetd/server' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ greeting: z.string(), config: z.object({ @@ -44,6 +44,9 @@ const data = await Data.create() .useTargeting({ country: targetIncludes(z.string()), }) + .build() + +const data = await Data.create(schema) .addRules('greeting', [ { targeting: { country: ['US'] }, @@ -160,11 +163,11 @@ Creates an Express server with targeting endpoints. ### Basic Server ```typescript -import { Data, targetEquals, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetEquals, targetIncludes } from '@targetd/api' import { createServer } from '@targetd/server' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ banner: z.string(), feature: z.object({ @@ -176,6 +179,9 @@ const data = await Data.create() platform: targetIncludes(z.string()), isPremium: targetEquals(z.boolean()), }) + .build() + +const data = await Data.create(schema) .addRules('banner', [ { targeting: { platform: ['mobile'] }, @@ -223,14 +229,17 @@ curl http://localhost:3000/?platform=mobile Use a function to provide data for dynamic updates: ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import { watch } from '@targetd/fs' import { createServer } from '@targetd/server' -let currentData = await Data.create() - .usePayload({ - content: z.string(), - }) +const baseData = await Data.create( + DataSchema.create() + .usePayload({ content: z.string() }) + .build(), +) + +let currentData = baseData // Watch for file changes watch(baseData, './rules', (error, updatedData) => { @@ -250,11 +259,11 @@ server.listen(3000) Create REST-friendly URLs using path parameters: ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { createServer } from '@targetd/server' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ content: z.string(), }) @@ -262,25 +271,27 @@ const data = await Data.create() region: targetIncludes(z.string()), language: targetIncludes(z.string()), }) - .addRules('content', [ - { - targeting: { - region: ['US'], - language: ['en'], - }, - payload: 'US English content', - }, - { - targeting: { - region: ['US'], - language: ['es'], - }, - payload: 'US Spanish content', + .build() + +const data = await Data.create(schema).addRules('content', [ + { + targeting: { + region: ['US'], + language: ['en'], }, - { - payload: 'Default content', + payload: 'US English content', + }, + { + targeting: { + region: ['US'], + language: ['es'], }, - ]) + payload: 'US Spanish content', + }, + { + payload: 'Default content', + }, +]) const server = createServer(data, { pathStructure: ['region', 'language'], @@ -306,7 +317,7 @@ Integrate into an existing Express application: ```typescript import express from 'express' -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import { createServer } from '@targetd/server' const app = express() @@ -317,9 +328,11 @@ app.get('/health', (req, res) => { }) // Add targetd endpoints -const data = await Data.create() - .usePayload({ config: z.object({ version: z.string() }) }) - .addRules('config', [{ payload: { version: '1.0.0' } }]) +const data = await Data.create( + DataSchema.create() + .usePayload({ config: z.object({ version: z.string() }) }) + .build(), +).addRules('config', [{ payload: { version: '1.0.0' } }]) createServer(data, { app }) @@ -334,32 +347,34 @@ Combine with [@targetd/date-range](https://jsr.io/@targetd/date-range) for time-based content: ```typescript -import { Data } from '@targetd/api' +import { Data, DataSchema } from '@targetd/api' import { createServer } from '@targetd/server' import dateRangeTargeting from '@targetd/date-range' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ campaign: z.string(), }) .useTargeting({ date: dateRangeTargeting, }) - .addRules('campaign', [ - { - targeting: { - date: { - start: '2024-12-01', - end: '2024-12-31', - }, + .build() + +const data = await Data.create(schema).addRules('campaign', [ + { + targeting: { + date: { + start: '2024-12-01', + end: '2024-12-31', }, - payload: '🎄 Holiday Campaign', - }, - { - payload: 'Regular Campaign', }, - ]) + payload: '🎄 Holiday Campaign', + }, + { + payload: 'Regular Campaign', + }, +]) createServer(data).listen(3000) ``` @@ -379,30 +394,32 @@ curl 'http://localhost:3000/campaign?date[start]=2024-12-15' Handle nested and array query parameters: ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { createServer } from '@targetd/server' import { z } from 'zod' -const data = await Data.create() +const schema = DataSchema.create() .usePayload({ recommendations: z.array(z.string()), }) .useTargeting({ interests: targetIncludes(z.string()), }) - .addRules('recommendations', [ - { - targeting: { interests: ['sports'] }, - payload: ['Football News', 'Basketball Scores'], - }, - { - targeting: { interests: ['tech'] }, - payload: ['Latest Gadgets', 'Programming Tips'], - }, - { - payload: ['General News'], - }, - ]) + .build() + +const data = await Data.create(schema).addRules('recommendations', [ + { + targeting: { interests: ['sports'] }, + payload: ['Football News', 'Basketball Scores'], + }, + { + targeting: { interests: ['tech'] }, + payload: ['Latest Gadgets', 'Programming Tips'], + }, + { + payload: ['General News'], + }, +]) createServer(data).listen(3000) ``` @@ -496,16 +513,18 @@ The server is designed to work seamlessly with **Server:** ```typescript -import { Data, targetIncludes } from '@targetd/api' +import { Data, DataSchema, targetIncludes } from '@targetd/api' import { createServer } from '@targetd/server' -export const data = await Data.create() +const schema = DataSchema.create() .usePayload({ greeting: z.string() }) .useTargeting({ country: targetIncludes(z.string()) }) - .addRules('greeting', [ - { targeting: { country: ['US'] }, payload: 'Hello!' }, - { payload: 'Hi!' }, - ]) + .build() + +export const data = await Data.create(schema).addRules('greeting', [ + { targeting: { country: ['US'] }, payload: 'Hello!' }, + { payload: 'Hi!' }, +]) createServer(data).listen(3000) ``` diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 605c98276..385808d60 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -43,17 +43,19 @@ export interface CreateServerOptions< * * @example Basic server: * ```ts - * import { Data, targetIncludes } from '@targetd/api' + * import { Data, DataSchema, targetIncludes } from '@targetd/api' * import { createServer } from '@targetd/server' * import { z } from 'zod' * - * const data = await Data.create() - * .usePayload({ greeting: z.string() }) - * .useTargeting({ country: targetIncludes(z.string()) }) - * .addRules('greeting', [ - * { targeting: { country: ['US'] }, payload: 'Hello!' }, - * { payload: 'Hi!' } - * ]) + * const data = await Data.create( + * DataSchema.create() + * .usePayload({ greeting: z.string() }) + * .useTargeting({ country: targetIncludes(z.string()) }) + * .build(), + * ).addRules('greeting', [ + * { targeting: { country: ['US'] }, payload: 'Hello!' }, + * { payload: 'Hi!' } + * ]) * * createServer(data).listen(3000) * // GET /greeting?country=US → "Hello!" diff --git a/packages/server/test/index.test.ts b/packages/server/test/index.test.ts index bd67a1f21..35b7d35f8 100644 --- a/packages/server/test/index.test.ts +++ b/packages/server/test/index.test.ts @@ -1,5 +1,11 @@ import { assertSnapshot } from 'jsr:@std/testing/snapshot' -import { Data, type DT, targetEquals, targetIncludes } from '@targetd/api' +import { + Data, + DataSchema, + type DT, + targetEquals, + targetIncludes, +} from '@targetd/api' import dateRangeTargeting from '@targetd/date-range' import _ from 'npm:lodash' import { setTimeout } from 'node:timers/promises' @@ -135,28 +141,31 @@ async function createDisposableServer(options?: { } function createData() { - return Data.create() - .usePayload({ - foo: z.string(), - bar: z.number(), - timed: z.string(), - }) - .useTargeting({ - weather: targetIncludes(z.string()), - highTide: targetEquals(z.boolean()), - asyncThing: { - predicate: (q) => - setTimeout(10, (t) => q === t && setTimeout(10, true)), - queryParser: z.boolean(), - targetingParser: z.boolean(), - }, - arrayThing: { - predicate: (q) => (t) => _.difference(q, t).length === 0, - queryParser: z.string().array(), - targetingParser: z.string().array(), - }, - date: dateRangeTargeting, - }) + return Data.create( + DataSchema.create() + .usePayload({ + foo: z.string(), + bar: z.number(), + timed: z.string(), + }) + .useTargeting({ + weather: targetIncludes(z.string()), + highTide: targetEquals(z.boolean()), + asyncThing: { + predicate: (q) => + setTimeout(10, (t) => q === t && setTimeout(10, true)), + queryParser: z.boolean(), + targetingParser: z.boolean(), + }, + arrayThing: { + predicate: (q) => (t) => _.difference(q, t).length === 0, + queryParser: z.string().array(), + targetingParser: z.string().array(), + }, + date: dateRangeTargeting, + }) + .build(), + ) .addRules('foo', [ { targeting: { @@ -224,15 +233,18 @@ function createData() { } Deno.test('custom path structure', async () => { - const data = await Data.create() - .usePayload({ - content: z.string(), - config: z.number(), - }) - .useTargeting({ - region: targetIncludes(z.string()), - device: targetIncludes(z.string()), - }) + const data = await Data.create( + DataSchema.create() + .usePayload({ + content: z.string(), + config: z.number(), + }) + .useTargeting({ + region: targetIncludes(z.string()), + device: targetIncludes(z.string()), + }) + .build(), + ) .addRules('content', [ { targeting: { From 742c178cc411877340056d3763ead3a013ca351c Mon Sep 17 00:00:00 2001 From: John Wright Date: Mon, 20 Apr 2026 15:22:00 +0100 Subject: [PATCH 3/4] refactor: update import statements for consistency and clarity --- packages/api/src/Data.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/src/Data.ts b/packages/api/src/Data.ts index 206110913..3dee2da34 100644 --- a/packages/api/src/Data.ts +++ b/packages/api/src/Data.ts @@ -16,12 +16,12 @@ import type * as DT from './types/Data.ts' import type * as FTTT from './types/FallThroughTargeting.ts' import type * as PT from './types/Payload.ts' import type * as QT from './types/Query.ts' -import { - type $InferObjectOutput, - type $strict, - type $ZodOptional, - type $ZodType, - type output, +import type { + $InferObjectOutput, + $strict, + $ZodOptional, + $ZodType, + output, } from 'zod/v4/core' import { partial, strictObject } from 'zod/mini' import { PromisedData } from './PromisedData.ts' From 6ea71ee1016c12a20b082a0c61701c63419e6db7 Mon Sep 17 00:00:00 2001 From: John Wright Date: Mon, 20 Apr 2026 15:27:53 +0100 Subject: [PATCH 4/4] refactor(DataSchema): enhance schema immutability and clarify key uniqueness in documentation --- packages/api/src/DataSchema.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/api/src/DataSchema.ts b/packages/api/src/DataSchema.ts index 1bffd4453..50e66eae3 100644 --- a/packages/api/src/DataSchema.ts +++ b/packages/api/src/DataSchema.ts @@ -29,6 +29,14 @@ export interface BuiltDataSchema<$ extends DT.Meta> { * parameters — this avoids the deep mapped-type instantiation that caused * TS2589 when chaining many schema calls directly on {@link Data}. * + * **Key uniqueness:** payload, targeting, query, and fall-through targeting + * names are expected to be unique across calls. Registering the same key + * twice is a user error: at runtime the later registration wins (object + * spread), but at the type level the entries are intersected. If the two + * parsers have incompatible types, the intersected key may resolve to + * `never`. Use unique names, or call {@link DataSchema.create} again to + * start a fresh configuration. + * * @example * ```ts * const schema = DataSchema.create() @@ -158,6 +166,10 @@ export class DataSchema< /** * Materialise the accumulated configuration into a {@link BuiltDataSchema} * suitable for passing to {@link Data.create}. + * + * The returned object and each nested parser record are frozen, so the + * built schema is safe to share between {@link Data} instances without + * risk of external mutation changing behaviour. */ build(): BuiltDataSchema<{ PayloadParsers: PP @@ -172,11 +184,15 @@ export class DataSchema< FallThroughTargetingParsers: FP } return Object.freeze({ - payloadParsers: this.#payloadParsers, - targetingParsers: this.#targetingParsers, - queryParsers: this.#queryParsers, - fallThroughTargetingParsers: this.#fallThroughTargetingParsers, - targetingPredicates: this.#targetingPredicates as TargetingPredicates<$>, + payloadParsers: Object.freeze({ ...this.#payloadParsers }), + targetingParsers: Object.freeze({ ...this.#targetingParsers }), + queryParsers: Object.freeze({ ...this.#queryParsers }), + fallThroughTargetingParsers: Object.freeze({ + ...this.#fallThroughTargetingParsers, + }), + targetingPredicates: Object.freeze({ + ...this.#targetingPredicates, + }) as TargetingPredicates<$>, }) } }