From 885b54638afbbcf46159dfaee6cdf069061ad7f0 Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 10 Apr 2026 12:33:03 +0100 Subject: [PATCH 1/3] feat: introduce data interfaces Introduce ConfigurableData, InsertableData, and QueryableData. --- packages/api/src/ConfigurableData.ts | 73 +++++++++++++++++++++++++ packages/api/src/Data.ts | 19 ++++--- packages/api/src/IData.ts | 58 +++----------------- packages/api/src/InsertableData.ts | 23 ++++++++ packages/api/src/PromisedData.ts | 19 ++++--- packages/api/src/QueryableData.ts | 55 +++++++++++++++++++ packages/api/src/index.ts | 3 ++ packages/client/src/index.ts | 79 ++++++++++++++++++++++------ packages/server/src/index.ts | 15 ++++++ 9 files changed, 265 insertions(+), 79 deletions(-) create mode 100644 packages/api/src/ConfigurableData.ts create mode 100644 packages/api/src/InsertableData.ts create mode 100644 packages/api/src/QueryableData.ts diff --git a/packages/api/src/ConfigurableData.ts b/packages/api/src/ConfigurableData.ts new file mode 100644 index 00000000..f424bd1e --- /dev/null +++ b/packages/api/src/ConfigurableData.ts @@ -0,0 +1,73 @@ +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, + * 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< + Merge<$, { PayloadParsers: Merge<$['PayloadParsers'], 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< + Merge<$, { + TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord> + QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord> + }> + > + > + + /** + * 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< + Merge<$, { + FallThroughTargetingParsers: Merge< + $['FallThroughTargetingParsers'], + FTTT.ParsersRecord + > + }> + > + > +} diff --git a/packages/api/src/Data.ts b/packages/api/src/Data.ts index c1de017e..9c053552 100644 --- a/packages/api/src/Data.ts +++ b/packages/api/src/Data.ts @@ -29,7 +29,9 @@ import { import { partial, strictObject } from 'zod/mini' import { PromisedData } from './PromisedData.ts' import { resolveVariables } from './parsers/DataItemVariableResolver.ts' -import type { IData } from './IData.ts' +import type { ConfigurableData } from './ConfigurableData.ts' +import type { InsertableData } from './InsertableData.ts' +import type { QueryableData } from './QueryableData.ts' /** * In-memory data store. @@ -59,7 +61,8 @@ import type { IData } from './IData.ts' * ) * ``` */ -export default class Data<$ extends DT.Meta> implements IData<$> { +export default class Data<$ extends DT.Meta> + implements ConfigurableData<$>, InsertableData<$>, QueryableData<$> { readonly #fallThroughTargetingParsers: $['FallThroughTargetingParsers'] readonly #data: DataItemsOut<$> readonly #payloadParsers: $['PayloadParsers'] @@ -533,14 +536,18 @@ export default class Data<$ extends DT.Meta> implements IData<$> { ): Promise< Data< Merge<$, { - FallThroughTargetingParsers: - Merge<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> + FallThroughTargetingParsers: Merge< + $['FallThroughTargetingParsers'], + FTTT.ParsersRecord + > }> > > { type $$ = Merge<$, { - FallThroughTargetingParsers: - Merge<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> + FallThroughTargetingParsers: Merge< + $['FallThroughTargetingParsers'], + FTTT.ParsersRecord + > }> const fallThroughTargetingParsers = this.#mergeFallThroughTargeting( diff --git a/packages/api/src/IData.ts b/packages/api/src/IData.ts index 77a31878..1ecac218 100644 --- a/packages/api/src/IData.ts +++ b/packages/api/src/IData.ts @@ -1,54 +1,10 @@ -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 PT from './types/Payload.ts' -import type * as QT from './types/Query.ts' -import type * as TT from './types/Targeting.ts' -import type { Merge } from './util.ts' -export interface IData<$ extends DT.Meta> { - usePayload( - parsers: Parsers, - ): Promise }>>> +export type { ConfigurableData } from './ConfigurableData.ts' +export type { InsertableData } from './InsertableData.ts' +export type { QueryableData } from './QueryableData.ts' - useTargeting(targeting: TDs): Promise< - IData< - Merge<$, { - TargetingParsers: Merge<$['TargetingParsers'], TT.ParserRecord> - QueryParsers: Merge<$['QueryParsers'], QT.ParserRecord> - }> - > - > - - useFallThroughTargeting( - targeting: TDs, - ): Promise< - IData< - Merge<$, { - FallThroughTargetingParsers: - Merge<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> - }> - > - > - - insert(data: DT.InsertableData<$>): Promise> - - getPayloadForEachName( - rawQuery?: QT.Raw<$['QueryParsers']>, - ): Promise> - - getPayload( - name: Name, - rawQuery?: QT.Raw<$['QueryParsers']>, - ): Promise< - | PT.Payload<$, $['PayloadParsers'][Name]> - | undefined - > - - getPayloads( - name: Name, - rawQuery?: QT.Raw<$['QueryParsers']>, - ): Promise< - PT.Payload<$, $['PayloadParsers'][Name]>[] - > -} +export type IData<$ extends DT.Meta> = + & import('./ConfigurableData.ts').ConfigurableData<$> + & import('./InsertableData.ts').InsertableData<$> + & import('./QueryableData.ts').QueryableData<$> diff --git a/packages/api/src/InsertableData.ts b/packages/api/src/InsertableData.ts new file mode 100644 index 00000000..0a86c676 --- /dev/null +++ b/packages/api/src/InsertableData.ts @@ -0,0 +1,23 @@ +import type * as DT from './types/Data.ts' + +/** + * Interface for inserting rule data into a {@link Data} instance. + * + * @template $ - The metadata type extending {@link DT.Meta} + * + * @example + * ```ts + * const updated = await data.insert({ + * greeting: [{ targeting: { country: ['US'] }, payload: 'Hello!' }], + * }) + * ``` + */ +export interface InsertableData<$ extends DT.Meta> { + /** + * Insert rule data into the instance. + * + * @param data - The data to insert, keyed by payload name. + * @returns A new instance with the inserted data. + */ + insert(data: DT.InsertableData<$>): Promise> +} diff --git a/packages/api/src/PromisedData.ts b/packages/api/src/PromisedData.ts index 7eee5dea..06249ff1 100644 --- a/packages/api/src/PromisedData.ts +++ b/packages/api/src/PromisedData.ts @@ -8,11 +8,14 @@ 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 { IData } from './IData.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 IData interface. + * 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. * @@ -20,10 +23,12 @@ import type { Merge } from './util.ts' * * @template $ - The metadata type extending Data.Meta * @extends {Promise>} - * @implements {IData<$>} + * @implements {ConfigurableData<$>} + * @implements {InsertableData<$>} + * @implements {QueryableData<$>} */ export class PromisedData<$ extends DT.Meta> extends Promise> - implements IData<$> { + implements ConfigurableData<$>, InsertableData<$>, QueryableData<$> { /** * Creates a new PromisedData instance. * @@ -138,8 +143,10 @@ export class PromisedData<$ extends DT.Meta> extends Promise> targeting: TDs, ): PromisedData< Merge<$, { - FallThroughTargetingParsers: - Merge<$['FallThroughTargetingParsers'], FTTT.ParsersRecord> + FallThroughTargetingParsers: Merge< + $['FallThroughTargetingParsers'], + FTTT.ParsersRecord + > }> > { return this.#create((data) => data.useFallThroughTargeting(targeting)) diff --git a/packages/api/src/QueryableData.ts b/packages/api/src/QueryableData.ts new file mode 100644 index 00000000..00d35618 --- /dev/null +++ b/packages/api/src/QueryableData.ts @@ -0,0 +1,55 @@ +import type * as DT from './types/Data.ts' +import type * as PT from './types/Payload.ts' +import type * as QT from './types/Query.ts' + +/** + * Interface for querying payloads from a {@link Data} instance. + * + * @template $ - The metadata type extending {@link DT.Meta} + * + * @example + * ```ts + * const greeting = await data.getPayload('greeting', { country: 'US' }) + * const all = await data.getPayloadForEachName({ country: 'US' }) + * ``` + */ +export interface QueryableData<$ extends DT.Meta> { + /** + * Retrieve the first matching payload for every registered payload name. + * + * @param rawQuery - Optional query object for targeting. + * @returns An object mapping payload names to their matched values. + */ + getPayloadForEachName( + rawQuery?: QT.Raw<$['QueryParsers']>, + ): Promise> + + /** + * Retrieve the first matching payload for a specific name. + * + * @param name - The payload name to look up. + * @param rawQuery - Optional query object for targeting. + * @returns The matched payload, or `undefined` if no rule matched. + */ + getPayload( + name: Name, + rawQuery?: QT.Raw<$['QueryParsers']>, + ): Promise< + | PT.Payload<$, $['PayloadParsers'][Name]> + | undefined + > + + /** + * Retrieve all matching payloads for a specific name. + * + * @param name - The payload name to look up. + * @param rawQuery - Optional query object for targeting. + * @returns An array of all matching payloads. + */ + getPayloads( + name: Name, + rawQuery?: QT.Raw<$['QueryParsers']>, + ): Promise< + PT.Payload<$, $['PayloadParsers'][Name]>[] + > +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 160a2294..7c5f05c1 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -11,6 +11,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' diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f6dea290..d8e215e4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,4 @@ -import type { Data, DT, PT, StaticRecord } from '@targetd/api' +import type { Data, DT, PT, QT, QueryableData } from '@targetd/api' import { queryToURLSearchParams } from './queryToURLSearchParams.ts' import { ZodError } from 'zod' import { ResponseError } from './ResponseError.ts' @@ -18,7 +18,7 @@ import { ResponseError } from './ResponseError.ts' * const allPayloads = await client.getPayloadForEachName({ country: 'US' }) * ``` */ -export class Client<$ extends DT.Meta> { +export class Client<$ extends DT.Meta> implements QueryableData<$> { #baseURL: string #data: Data<$> @@ -69,12 +69,12 @@ export class Client<$ extends DT.Meta> { */ async getPayload( name: Name, - rawQuery: Partial> = {}, + rawQuery?: QT.Raw<$['QueryParsers']>, ): Promise< | PT.Payload<$, $['PayloadParsers'][Name]> - | void + | undefined > { - const query = this.#data.QueryParser.parse(rawQuery) + const query = this.#data.QueryParser.parse(rawQuery ?? {}) const urlSearchParams = queryToURLSearchParams(query) const response = await fetch( `${this.#baseURL}/${String(name)}?${urlSearchParams}`, @@ -122,17 +122,9 @@ export class Client<$ extends DT.Meta> { * ``` */ async getPayloadForEachName( - rawQuery: Partial> = {}, - ): Promise< - Partial< - { - [Name in keyof $['PayloadParsers']]: - | PT.Payload<$, $['PayloadParsers'][Name]> - | undefined - } - > - > { - const query = this.#data.QueryParser.parse(rawQuery) + rawQuery?: QT.Raw<$['QueryParsers']>, + ): Promise> { + const query = this.#data.QueryParser.parse(rawQuery ?? {}) const urlSearchParams = queryToURLSearchParams(query) const response = await fetch(`${this.#baseURL}?${urlSearchParams}`, { method: 'GET', @@ -141,6 +133,61 @@ export class Client<$ extends DT.Meta> { const data = await this.#data.insert((await response.json()) as any) return data.getPayloadForEachName() } + + /** + * Fetch all matching payloads from the server by name. + * + * @param name - The name of the payload to retrieve. + * @param rawQuery - Optional query object with targeting parameters. + * @returns Array of all matching payloads. + * @throws {ZodError} When query parameters fail validation. + * @throws {ResponseError} When the server returns an error response. + * + * @example + * ```ts + * const greetings = await client.getPayloads('greeting', { country: 'US' }) + * // Returns: ['Hello!', 'Hi!'] + * ``` + */ + async getPayloads( + name: Name, + rawQuery?: QT.Raw<$['QueryParsers']>, + ): Promise< + PT.Payload<$, $['PayloadParsers'][Name]>[] + > { + const query = this.#data.QueryParser.parse(rawQuery ?? {}) + const urlSearchParams = queryToURLSearchParams(query) + const response = await fetch( + `${this.#baseURL}/${String(name)}/all?${urlSearchParams}`, + { + method: 'GET', + ...this.#init, + }, + ) + + switch (true) { + case response.status === 400: + await response.json() + .then( + (error) => { + if (error.name === '$ZodError') { + throw new ZodError(JSON.parse(error.message)) + } + }, + () => {}, + ) + // fallthrough + case response.status > 200 || response.status < 200: + throw new ResponseError(response) + default: { + const payloads = await response.json() as any[] + const data = await this.#data.insert({ + [name]: payloads.map((payload) => ({ payload })), + } as any) + return data.getPayloads(name) + } + } + } } /** diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cd6e78c5..605c9827 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -109,6 +109,21 @@ export function createServer< } return server + .get( + '/:name/all', + castQueryProp(), + castQueryArrayProps(getData), + async (req, res) => { + const query = res.locals.query ?? req.query + const data = await getData() + + if (!(req.params.name in data.payloadParsers)) { + throw new StatusError(404, `Unknown data property ${req.params.name}`) + } + + res.json(await data.getPayloads(req.params.name, query)) + }, + ) .get( '/:name', castQueryProp(), From cc71a2cb5cf3ce7404a73da540122884a73fa858 Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 10 Apr 2026 12:34:31 +0100 Subject: [PATCH 2/3] chore: remove unused file --- packages/api/src/IData.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 packages/api/src/IData.ts diff --git a/packages/api/src/IData.ts b/packages/api/src/IData.ts deleted file mode 100644 index 1ecac218..00000000 --- a/packages/api/src/IData.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type * as DT from './types/Data.ts' - -export type { ConfigurableData } from './ConfigurableData.ts' -export type { InsertableData } from './InsertableData.ts' -export type { QueryableData } from './QueryableData.ts' - -export type IData<$ extends DT.Meta> = - & import('./ConfigurableData.ts').ConfigurableData<$> - & import('./InsertableData.ts').InsertableData<$> - & import('./QueryableData.ts').QueryableData<$> From e64b177937abf21ded60b328755635c7aed93125 Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 10 Apr 2026 13:32:16 +0100 Subject: [PATCH 3/3] feat: enhance error handling and add tests for matching payloads --- packages/client/src/index.ts | 29 +++++++++-- .../test/__snapshots__/index.test.ts.snap | 48 +++++++++++++++++++ packages/client/test/index.test.ts | 12 +++++ .../test/__snapshots__/index.test.ts.snap | 28 +++++++++++ packages/server/test/index.test.ts | 34 +++++++++++++ 5 files changed, 146 insertions(+), 5 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d8e215e4..8cebe45d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -130,8 +130,26 @@ export class Client<$ extends DT.Meta> implements QueryableData<$> { method: 'GET', ...this.#init, }) - const data = await this.#data.insert((await response.json()) as any) - return data.getPayloadForEachName() + + switch (true) { + case response.status === 400: + await response.json() + .then( + (error) => { + if (error.name === '$ZodError') { + throw new ZodError(JSON.parse(error.message)) + } + }, + () => {}, + ) + // fallthrough + case response.status > 200 || response.status < 200: + throw new ResponseError(response) + default: { + const data = await this.#data.insert((await response.json()) as any) + return data.getPayloadForEachName() + } + } } /** @@ -181,9 +199,10 @@ export class Client<$ extends DT.Meta> implements QueryableData<$> { throw new ResponseError(response) default: { const payloads = await response.json() as any[] - const data = await this.#data.insert({ - [name]: payloads.map((payload) => ({ payload })), - } as any) + const data = await this.#data.addRules( + name, + payloads.map((payload) => ({ payload })) as any, + ) return data.getPayloads(name) } } diff --git a/packages/client/test/__snapshots__/index.test.ts.snap b/packages/client/test/__snapshots__/index.test.ts.snap index 7ef71370..a8827777 100644 --- a/packages/client/test/__snapshots__/index.test.ts.snap +++ b/packages/client/test/__snapshots__/index.test.ts.snap @@ -45,3 +45,51 @@ snapshot[`get all 6`] = ` foo: "Async payload", } `; + +snapshot[`get all matching payloads 1`] = ` +[ + "bar", +] +`; + +snapshot[`get all matching payloads 2`] = ` +[ + { + __rules__: [ + { + payload: "😎", + targeting: {}, + }, + { + payload: "bar", + targeting: {}, + }, + ], + }, +] +`; + +snapshot[`get all matching payloads 3`] = ` +[ + { + __rules__: [ + { + payload: "🏄‍♂️", + targeting: {}, + }, + { + payload: "😎", + targeting: {}, + }, + { + payload: "🌊", + targeting: {}, + }, + { + payload: "bar", + targeting: {}, + }, + ], + }, +] +`; diff --git a/packages/client/test/index.test.ts b/packages/client/test/index.test.ts index e846ee02..4a8ca99f 100644 --- a/packages/client/test/index.test.ts +++ b/packages/client/test/index.test.ts @@ -125,6 +125,18 @@ Deno.test('get all', async (t) => { ) }) +Deno.test('get all matching payloads', async (t) => { + await using service = await startService() + const { client } = service + + await assertSnapshot(t, await client.getPayloads('foo')) + await assertSnapshot(t, await client.getPayloads('foo', { weather: 'sunny' })) + await assertSnapshot( + t, + await client.getPayloads('foo', { highTide: true, weather: 'sunny' }), + ) +}) + async function startService(): Promise< AsyncDisposable & { client: ClientWithData } > { diff --git a/packages/server/test/__snapshots__/index.test.ts.snap b/packages/server/test/__snapshots__/index.test.ts.snap index bc517ac1..0e2493b7 100644 --- a/packages/server/test/__snapshots__/index.test.ts.snap +++ b/packages/server/test/__snapshots__/index.test.ts.snap @@ -64,6 +64,34 @@ snapshot[`get all 6`] = ` } `; +snapshot[`get all matching payloads 1`] = ` +[ + "bar", +] +`; + +snapshot[`get all matching payloads 2`] = ` +[ + "😎", + "bar", +] +`; + +snapshot[`get all matching payloads 3`] = ` +[ + "🏄‍♂️", + "😎", + "🌊", + "bar", +] +`; + +snapshot[`get all matching payloads 4`] = ` +[ + "out of time", +] +`; + snapshot[`error handling 1`] = ` { issues: [ diff --git a/packages/server/test/index.test.ts b/packages/server/test/index.test.ts index b0c04cc7..bd67a1f2 100644 --- a/packages/server/test/index.test.ts +++ b/packages/server/test/index.test.ts @@ -361,6 +361,40 @@ Deno.test('custom path structure', async () => { }) }) +Deno.test('get all matching payloads', async (t) => { + await using server = await createDisposableServer() + + let response = await request(server) + .get('/foo/all') + .expect('Content-Type', /json/) + .expect(200) + await assertSnapshot(t, response.body) + + response = await request(server) + .get('/foo/all?weather=sunny') + .expect('Content-Type', /json/) + .expect(200) + await assertSnapshot(t, response.body) + + response = await request(server) + .get('/foo/all?highTide=true&weather=sunny') + .expect('Content-Type', /json/) + .expect(200) + await assertSnapshot(t, response.body) + + // Empty results + response = await request(server) + .get('/timed/all') + .expect('Content-Type', /json/) + .expect(200) + await assertSnapshot(t, response.body) + + // 404 for unknown name + await request(server) + .get('/nonexistent/all') + .expect(404) +}) + Deno.test('error handling', async (t) => { await using server = await createDisposableServer()