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}.
+ *
+ * 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
+ TargetingParsers: TP
+ QueryParsers: QP
+ FallThroughTargetingParsers: FP
+ }> {
+ type $ = {
+ PayloadParsers: PP
+ TargetingParsers: TP
+ QueryParsers: QP
+ FallThroughTargetingParsers: FP
+ }
+ return Object.freeze({
+ 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<$>,
+ })
+ }
+}
diff --git a/packages/api/src/PromisedData.ts b/packages/api/src/PromisedData.ts
index 06249ff12..27cad855f 100644
--- a/packages/api/src/PromisedData.ts
+++ b/packages/api/src/PromisedData.ts
@@ -1,34 +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'
-import type { Merge } from './util.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.
*
@@ -59,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<
- Merge<$, { PayloadParsers: Merge<$['PayloadParsers'], Parsers> }>
- > {
- return this.#create((data) => data.usePayload(parsers))
- }
-
/**
* Inserts data items into the dataset.
*
@@ -113,45 +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<
- Merge<$, {
- TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord>
- QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord>
- }>
- > {
- 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<
- Merge<$, {
- FallThroughTargetingParsers: Merge<
- $['FallThroughTargetingParsers'],
- 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 7c5f05c13..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,11 +13,9 @@ 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'
-export type { Merge } from './util.ts'
/**
* Type helpers for the {@link Data} class.
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/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,
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: {