Initial Commit
This commit is contained in:
111
packages/commerce/contacts/audience-rules/README.md
Normal file
111
packages/commerce/contacts/audience-rules/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @suaveplan/audience-rules - Universal audience segmentation: rule-based filtering with 14 comparison operators, AND/OR chaining, dot-notation field resolution including array indices, three refresh modes (realtime/scheduled/manual), seven predicate factories, pluggable SegmentStore with InMemorySegmentStore (Clock + IdGenerator injectable), SegmentRefresher with typed EventBus emission, Zod-validated boundaries, and AudienceRulesError with stable AUDIENCE_RULES.* code registry.
|
||||
|
||||
Universal audience segmentation: rule-based filtering on contact data
|
||||
with 14 comparison operators, AND/OR chaining, dot-notation field
|
||||
resolution including array indices, three refresh modes
|
||||
(realtime/scheduled/manual), seven predicate factory helpers, a pluggable
|
||||
`SegmentStore` with an `InMemorySegmentStore` (injectable `Clock` +
|
||||
`IdGenerator`), and a `SegmentRefresher` that emits `segment:refreshed`
|
||||
on a typed `EventBus<SegmentRefresherEventMap>`. Inputs are
|
||||
Zod-validated; failures raise `AudienceRulesError` with a stable
|
||||
`AUDIENCE_RULES.*` code registry.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable, namespaced error-code registry for the `@suaveplan/audience-rules` package, alongs…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable, namespaced error-code registry for the `@suaveplan/audience-rules` package, alongs…
|
||||
- [predicates](./src/predicates/predicates.md) — `predicates.ts` ships seven factory functions that produce `SegmentRule` values for the most common audience filters: t…
|
||||
- [predicates](./src/predicates/predicates.md) — `predicates.ts` ships seven factory functions that produce `SegmentRule` values for the most common audience filters: t…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes the Zod validators that guard every public input boundary on the `@suaveplan/audience-rules` store…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes the Zod validators that guard every public input boundary on the `@suaveplan/audience-rules` store…
|
||||
- [segment-evaluator](./src/segment-evaluator/segment-evaluator.md) — `segment-evaluator.ts` provides the pure-function rule engine that decides, for a given `Segment` and a tenant's `Conta…
|
||||
- [segment-evaluator](./src/segment-evaluator/segment-evaluator.md) — `segment-evaluator.ts` provides the pure-function rule engine that decides, for a given `Segment` and a tenant's `Conta…
|
||||
- [segment-refresher](./src/segment-refresher/segment-refresher.md) — `segment-refresher.ts` orchestrates re-evaluation of one or many segments and persists the resulting `contactIds` back…
|
||||
- [segment-refresher](./src/segment-refresher/segment-refresher.md) — `segment-refresher.ts` orchestrates re-evaluation of one or many segments and persists the resulting `contactIds` back…
|
||||
- [store](./src/store/store.md) — `store.ts` defines the `SegmentStore` interface that abstracts segment persistence behind a six-method contract (`creat…
|
||||
- [store](./src/store/store.md) — `store.ts` defines the `SegmentStore` interface that abstracts segment persistence behind a six-method contract (`creat…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audience-rules
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemorySegmentStore,
|
||||
SegmentEvaluator,
|
||||
SegmentRefresher,
|
||||
hasTag,
|
||||
statusIs,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
const provider = async () => [
|
||||
{ id: "c1", tags: ["vip"], status: "active" },
|
||||
{ id: "c2", tags: ["basic"], status: "active" },
|
||||
{ id: "c3", tags: ["vip"], status: "unsubscribed" },
|
||||
];
|
||||
|
||||
const store = new InMemorySegmentStore();
|
||||
const evaluator = new SegmentEvaluator(provider);
|
||||
const refresher = new SegmentRefresher(store, evaluator);
|
||||
|
||||
const segment = await store.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Active VIPs",
|
||||
rules: [hasTag("vip"), statusIs("active")],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
|
||||
refresher.events.on("segment:refreshed", ({ segmentId, result }) => {
|
||||
console.log(`${segmentId}: ${result.total} matches`);
|
||||
});
|
||||
|
||||
await refresher.refresh(segment.id);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [error-codes](src/error-codes/error-codes.md) — `AUDIENCE_RULES_CODES`
|
||||
registry + `AudienceRulesError`.
|
||||
- [schemas](src/schemas/schemas.md) — Zod validators + `parseOrThrow`.
|
||||
- [store](src/store/store.md) — `SegmentStore` interface +
|
||||
`InMemorySegmentStore` with `Clock` + `IdGenerator` injection.
|
||||
- [segment-evaluator](src/segment-evaluator/segment-evaluator.md) —
|
||||
14-operator rule engine with dot-notation field resolution and AND/OR
|
||||
chaining.
|
||||
- [segment-refresher](src/segment-refresher/segment-refresher.md) —
|
||||
re-evaluation orchestrator with typed `EventBus` emission.
|
||||
- [predicates](src/predicates/predicates.md) — seven `SegmentRule` factory
|
||||
helpers for tags, channels, status, activity dates, and metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- 14 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`,
|
||||
`not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`,
|
||||
`ends_with`.
|
||||
- AND/OR logical chaining via `logicalOp` on each rule.
|
||||
- Dot-notation field paths with numeric array indices
|
||||
(`channels.0.type`).
|
||||
- Three refresh modes for cache policy: `realtime`, `scheduled`, `manual`.
|
||||
- Multi-tenant isolation via `tenantId` on `Segment` and `list`.
|
||||
- Predicate factories for the seven most common audience filters.
|
||||
- Pluggable storage — provide a `SegmentStore` implementation backed by
|
||||
the persistence layer of your choice. `InMemorySegmentStore` is the
|
||||
reference implementation.
|
||||
- Typed event emission via `EventBus<SegmentRefresherEventMap>` from
|
||||
`@suaveplan/events`.
|
||||
- Stable `AUDIENCE_RULES.*` code registry with `INVALID_INPUT`,
|
||||
`SEGMENT_NOT_FOUND`, `UNKNOWN_OPERATOR`, `PROVIDER_FAILED`.
|
||||
|
||||
## See also
|
||||
|
||||
- `@suaveplan/contacts` — contact and group management, provides the
|
||||
`ContactData` consumed by audience-rules.
|
||||
- `@suaveplan/events` — typed `EventBus<TMap>` substrate.
|
||||
- `@suaveplan/error` — `SuaveplanError` + `defineCodes`.
|
||||
|
||||
70
packages/commerce/contacts/audience-rules/package.json
Normal file
70
packages/commerce/contacts/audience-rules/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@suaveplan/audience-rules",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal audience segmentation: rule-based filtering with 14 comparison operators, AND/OR chaining, dot-notation field resolution including array indices, three refresh modes (realtime/scheduled/manual), seven predicate factories, pluggable SegmentStore with InMemorySegmentStore (Clock + IdGenerator injectable), SegmentRefresher with typed EventBus emission, Zod-validated boundaries, and AudienceRulesError with stable AUDIENCE_RULES.* code registry.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": { "types": "./dist/schemas/schemas.d.ts", "default": "./dist/schemas/schemas.js" }
|
||||
},
|
||||
"./store/store": {
|
||||
"import": { "types": "./dist/store/store.d.ts", "default": "./dist/store/store.js" }
|
||||
},
|
||||
"./segment-evaluator/segment-evaluator": {
|
||||
"import": { "types": "./dist/segment-evaluator/segment-evaluator.d.ts", "default": "./dist/segment-evaluator/segment-evaluator.js" }
|
||||
},
|
||||
"./segment-refresher/segment-refresher": {
|
||||
"import": { "types": "./dist/segment-refresher/segment-refresher.d.ts", "default": "./dist/segment-refresher/segment-refresher.js" }
|
||||
},
|
||||
"./predicates/predicates": {
|
||||
"import": { "types": "./dist/predicates/predicates.d.ts", "default": "./dist/predicates/predicates.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
const SRC_DIR = resolve(__dirname);
|
||||
|
||||
function walk(dir: string, files: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) walk(full, files);
|
||||
else if (full.endsWith(".ts")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("audience-rules spec-conformance", () => {
|
||||
it("src tree contains no browser or Node-only globals", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const banned = [
|
||||
/from\s+["']node:/,
|
||||
/\bglobalThis\.document\b/,
|
||||
/\bnavigator\.userAgent\b/,
|
||||
/\bwindow\.\b/,
|
||||
];
|
||||
for (const file of walk(SRC_DIR)) {
|
||||
if (file.endsWith(".test.ts")) continue;
|
||||
const text = readFileSync(file, "utf8");
|
||||
for (const pattern of banned) {
|
||||
expect(pattern.test(text)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every non-exempt test file imports from @suaveplan/testing/runner", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const file of walk(SRC_DIR)) {
|
||||
if (!file.endsWith(".test.ts")) continue;
|
||||
const text = readFileSync(file, "utf8");
|
||||
expect(text).toContain('from "@suaveplan/testing/runner"');
|
||||
expect(text).not.toMatch(/from\s+["'](vitest|bun:test)["']/);
|
||||
}
|
||||
});
|
||||
|
||||
it("AUDIENCE_RULES_CODES registry contains four codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const values = Object.values(AUDIENCE_RULES_CODES);
|
||||
expect(values).toHaveLength(4);
|
||||
for (const v of values) {
|
||||
expect(v.startsWith("AUDIENCE_RULES.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("AudienceRulesError instantiates cleanly for every code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(AUDIENCE_RULES_CODES)) {
|
||||
const err = new AudienceRulesError(code, "test");
|
||||
expect(err).toBeInstanceOf(AudienceRulesError);
|
||||
}
|
||||
});
|
||||
|
||||
it("package.json subpath exports match feature module count", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k !== "." && k !== "./package.json"
|
||||
);
|
||||
expect(subpaths).toContain("./error-codes/error-codes");
|
||||
expect(subpaths).toContain("./schemas/schemas");
|
||||
expect(subpaths).toContain("./store/store");
|
||||
expect(subpaths).toContain("./segment-evaluator/segment-evaluator");
|
||||
expect(subpaths).toContain("./segment-refresher/segment-refresher");
|
||||
expect(subpaths).toContain("./predicates/predicates");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` defines the stable, namespaced error-code registry for the
|
||||
`@suaveplan/audience-rules` package, alongside the discriminated
|
||||
`AudienceRulesError` class that every failure path raises. It exists so that
|
||||
consumers can dispatch on a stable string (`"AUDIENCE_RULES.SEGMENT_NOT_FOUND"`)
|
||||
rather than parsing error messages, and so that diagnostic context
|
||||
(`segmentId` / `tenantId`) survives transport across logger sinks, OTel span
|
||||
events, and structured-output channels without polluting the base
|
||||
`SuaveplanError` `context` map.
|
||||
|
||||
## Features
|
||||
|
||||
- `AUDIENCE_RULES_CODES` registry built via `defineCodes("AUDIENCE_RULES", [...])`
|
||||
with four stable codes: `INVALID_INPUT`, `SEGMENT_NOT_FOUND`,
|
||||
`UNKNOWN_OPERATOR`, `PROVIDER_FAILED`.
|
||||
- `AudienceRulesErrorCode` union type narrowing every legal `code` value.
|
||||
- `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` carrying
|
||||
optional `segmentId` and `tenantId` discriminants.
|
||||
- `AudienceRulesErrorOptions` extending `SuaveplanErrorOptions` with the two
|
||||
discriminant fields.
|
||||
- Compatible with the `@suaveplan/error` cause-chain traversal, redaction, and
|
||||
OTel span-event adapter without further wiring.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
throw new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.SEGMENT_NOT_FOUND,
|
||||
"segment 'seg_42' does not exist",
|
||||
{ segmentId: "seg_42", tenantId: "tenant-1" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudienceRulesError,
|
||||
AUDIENCE_RULES_CODES,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
function handleRefresh(error: unknown) {
|
||||
if (
|
||||
error instanceof AudienceRulesError &&
|
||||
error.code === AUDIENCE_RULES_CODES.PROVIDER_FAILED
|
||||
) {
|
||||
// narrow tenantId for diagnostics, then re-throw with extra context
|
||||
throw new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.PROVIDER_FAILED,
|
||||
"contact provider failed during scheduled refresh",
|
||||
{ tenantId: error.tenantId, cause: error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIENCE_RULES_CODES` — frozen record mapping every short name to its
|
||||
namespaced string (e.g. `INVALID_INPUT → "AUDIENCE_RULES.INVALID_INPUT"`).
|
||||
- `type AudienceRulesErrorCode` — union of every namespaced string value.
|
||||
- `interface AudienceRulesErrorOptions extends SuaveplanErrorOptions` — accepts
|
||||
`segmentId?: string`, `tenantId?: string`, plus every option from the base
|
||||
`SuaveplanErrorOptions` (e.g. `cause`, `context`).
|
||||
- `class AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` —
|
||||
constructor `(code, message, options?)`; exposes `segmentId?: string` and
|
||||
`tenantId?: string` as readonly own-properties.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is intentionally small at v0.1.0. Adding a code requires bumping
|
||||
the package and updating the OpenSpec delta; deleting a code is a breaking
|
||||
change. See `AGENTS.md §2c` — codes are never removed just because a
|
||||
call site has not been wired yet.
|
||||
- `segmentId` / `tenantId` are assigned only when the constructor option is
|
||||
non-`undefined`, so the runtime shape stays compact for logger redaction
|
||||
passes that walk own-property keys.
|
||||
- `defineCodes` from `@suaveplan/error` guarantees the namespaced prefix at
|
||||
type-time, so a typo in the literal (`"AUDIENCE_RULE.X"`) is a compile error,
|
||||
not a silent registry shift.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUDIENCE_RULES_CODES, AudienceRulesError } from "./error-codes.js";
|
||||
|
||||
describe("AUDIENCE_RULES_CODES + AudienceRulesError", () => {
|
||||
it("registers four namespaced codes", { timeout: 5000 }, () => {
|
||||
expect(AUDIENCE_RULES_CODES.INVALID_INPUT).toBe(
|
||||
"AUDIENCE_RULES.INVALID_INPUT"
|
||||
);
|
||||
expect(AUDIENCE_RULES_CODES.SEGMENT_NOT_FOUND).toBe(
|
||||
"AUDIENCE_RULES.SEGMENT_NOT_FOUND"
|
||||
);
|
||||
expect(AUDIENCE_RULES_CODES.UNKNOWN_OPERATOR).toBe(
|
||||
"AUDIENCE_RULES.UNKNOWN_OPERATOR"
|
||||
);
|
||||
expect(AUDIENCE_RULES_CODES.PROVIDER_FAILED).toBe(
|
||||
"AUDIENCE_RULES.PROVIDER_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("every registered code constructs an AudienceRulesError with namespaced code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(AUDIENCE_RULES_CODES)) {
|
||||
const err = new AudienceRulesError(code, "msg");
|
||||
expect(err).toBeInstanceOf(AudienceRulesError);
|
||||
expect(err.code).toBe(code);
|
||||
expect(err.message).toBe("msg");
|
||||
}
|
||||
});
|
||||
|
||||
it("every code exposes its discriminants on the instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.SEGMENT_NOT_FOUND,
|
||||
"missing",
|
||||
{ segmentId: "seg_1", tenantId: "tenant-1" }
|
||||
);
|
||||
expect(err.segmentId).toBe("seg_1");
|
||||
expect(err.tenantId).toBe("tenant-1");
|
||||
});
|
||||
|
||||
it("leaves discriminants undefined when not provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.INVALID_INPUT,
|
||||
"x"
|
||||
);
|
||||
expect(err.segmentId).toBeUndefined();
|
||||
expect(err.tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves cause across constructor", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.PROVIDER_FAILED,
|
||||
"wrapped",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/audience-rules`. Every public failure
|
||||
* path raises an {@link AudienceRulesError} carrying one of these codes; consumers
|
||||
* dispatch on the namespaced string (`"AUDIENCE_RULES.SEGMENT_NOT_FOUND"`).
|
||||
*/
|
||||
export const AUDIENCE_RULES_CODES = defineCodes("AUDIENCE_RULES", [
|
||||
"INVALID_INPUT",
|
||||
"SEGMENT_NOT_FOUND",
|
||||
"UNKNOWN_OPERATOR",
|
||||
"PROVIDER_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Union of every registered code. */
|
||||
export type AudienceRulesErrorCode =
|
||||
(typeof AUDIENCE_RULES_CODES)[keyof typeof AUDIENCE_RULES_CODES];
|
||||
|
||||
/** Options bundle accepted by the {@link AudienceRulesError} constructor. */
|
||||
export interface AudienceRulesErrorOptions extends SuaveplanErrorOptions {
|
||||
/** Segment identifier that triggered the error, when applicable. */
|
||||
readonly segmentId?: string;
|
||||
/** Tenant identifier the operation was scoped to, when applicable. */
|
||||
readonly tenantId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated error class for `@suaveplan/audience-rules`. Extends
|
||||
* `SuaveplanError` so `instanceof` narrows `code` to {@link AudienceRulesErrorCode}.
|
||||
* Carries optional `segmentId` / `tenantId` discriminants for diagnostics without
|
||||
* polluting the base `context` map.
|
||||
*/
|
||||
export class AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode> {
|
||||
public readonly segmentId?: string;
|
||||
public readonly tenantId?: string;
|
||||
|
||||
public constructor(
|
||||
code: AudienceRulesErrorCode,
|
||||
message: string,
|
||||
options: AudienceRulesErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
if (options.segmentId !== undefined) this.segmentId = options.segmentId;
|
||||
if (options.tenantId !== undefined) this.tenantId = options.tenantId;
|
||||
}
|
||||
}
|
||||
67
packages/commerce/contacts/audience-rules/src/index.ts
Normal file
67
packages/commerce/contacts/audience-rules/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* `@suaveplan/audience-rules` — universal audience segmentation: rule-based
|
||||
* filtering with 14 comparison operators, AND/OR chaining, dot-notation field
|
||||
* resolution, three refresh modes, predicate factories, pluggable
|
||||
* `SegmentStore` with `InMemorySegmentStore`, `SegmentRefresher` with typed
|
||||
* `EventBus` emission, Zod-validated boundaries, and `AudienceRulesError` with
|
||||
* stable `AUDIENCE_RULES.*` code registry.
|
||||
*/
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
type AudienceRulesErrorCode,
|
||||
type AudienceRulesErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Predicates
|
||||
export {
|
||||
channelIs,
|
||||
hasTag,
|
||||
lastActivityAfter,
|
||||
lastActivityBefore,
|
||||
metadataContains,
|
||||
metadataEquals,
|
||||
statusIs,
|
||||
} from "./predicates/predicates.js";
|
||||
// Schemas
|
||||
export {
|
||||
createSegmentInputSchema,
|
||||
logicalOpSchema,
|
||||
parseOrThrow,
|
||||
refreshModeSchema,
|
||||
segmentOperatorSchema,
|
||||
segmentRuleSchema,
|
||||
updateSegmentInputSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
// Evaluator
|
||||
export {
|
||||
type ContactDataProvider,
|
||||
SegmentEvaluator,
|
||||
} from "./segment-evaluator/segment-evaluator.js";
|
||||
// Refresher
|
||||
export {
|
||||
type SegmentRefreshedPayload,
|
||||
SegmentRefresher,
|
||||
type SegmentRefresherEventMap,
|
||||
} from "./segment-refresher/segment-refresher.js";
|
||||
// Store
|
||||
export {
|
||||
InMemorySegmentStore,
|
||||
type InMemorySegmentStoreOptions,
|
||||
type SegmentStore,
|
||||
} from "./store/store.js";
|
||||
// Entity types
|
||||
export type {
|
||||
ContactData,
|
||||
ContactDataChannel,
|
||||
ContactDataDelivery,
|
||||
CreateSegmentInput,
|
||||
LogicalOp,
|
||||
RefreshMode,
|
||||
Segment,
|
||||
SegmentEvaluationResult,
|
||||
SegmentOperator,
|
||||
SegmentRule,
|
||||
UpdateSegmentInput,
|
||||
} from "./types/segment.types.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# predicates
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`predicates.ts` ships seven factory functions that produce `SegmentRule`
|
||||
values for the most common audience filters: tag membership, channel type,
|
||||
status equality, activity-date windows, and metadata field equality /
|
||||
substring. Centralising these factories trims boilerplate when building
|
||||
segments programmatically (e.g. from a UI builder) and pins the field-path
|
||||
convention (`tags`, `channels.0.type`, `metadata.<field>`) so callers do not
|
||||
have to remember it.
|
||||
|
||||
## Features
|
||||
|
||||
- `hasTag(tag)` — matches `tags` contains tag.
|
||||
- `channelIs(type)` — matches first channel of given type.
|
||||
- `statusIs(status)` — matches exact status string.
|
||||
- `lastActivityBefore(date)` — matches `lastActivityAt < date.toISOString()`.
|
||||
- `lastActivityAfter(date)` — matches `lastActivityAt > date.toISOString()`.
|
||||
- `metadataEquals(field, value)` — matches `metadata.<field> === value`.
|
||||
- `metadataContains(field, value)` — matches `metadata.<field>` contains
|
||||
value (string substring or array membership).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hasTag, statusIs } from "@suaveplan/audience-rules";
|
||||
|
||||
const rules = [hasTag("vip"), statusIs("active")];
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
channelIs,
|
||||
lastActivityAfter,
|
||||
metadataEquals,
|
||||
type SegmentRule,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
// "US-based contacts who opened email since Jan 1, OR have a US push channel"
|
||||
const rules: SegmentRule[] = [
|
||||
metadataEquals("country", "US"),
|
||||
lastActivityAfter(new Date("2026-01-01")),
|
||||
{ ...channelIs("push"), logicalOp: "or" },
|
||||
];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `hasTag(tag: string): SegmentRule`
|
||||
- `channelIs(type: string): SegmentRule`
|
||||
- `statusIs(status: string): SegmentRule`
|
||||
- `lastActivityBefore(date: Date): SegmentRule`
|
||||
- `lastActivityAfter(date: Date): SegmentRule`
|
||||
- `metadataEquals(field: string, value: unknown): SegmentRule`
|
||||
- `metadataContains(field: string, value: unknown): SegmentRule`
|
||||
|
||||
Each factory returns a `SegmentRule` with `logicalOp` left undefined, so the
|
||||
default `"and"` chaining applies. Override `logicalOp` via spread to OR-chain
|
||||
a predicate with the previous rule.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The date predicates serialise the boundary date as ISO 8601. The
|
||||
evaluator's `gt` / `lt` operators compare via unchecked numeric casts,
|
||||
which works because ISO date strings are lexicographically ordered.
|
||||
- `channelIs` targets `channels.0.type` rather than walking the full
|
||||
channel array; this matches the upstream convention and reflects the
|
||||
fact that contacts are typically created with a primary channel at
|
||||
index 0.
|
||||
- All seven factories are pure and stateless, so they are safe to call in
|
||||
hot paths (e.g. on every keystroke in a segment builder UI).
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SegmentEvaluator } from "../segment-evaluator/segment-evaluator.js";
|
||||
import type { ContactData } from "../types/segment.types.js";
|
||||
import {
|
||||
channelIs,
|
||||
hasTag,
|
||||
lastActivityAfter,
|
||||
lastActivityBefore,
|
||||
metadataContains,
|
||||
metadataEquals,
|
||||
statusIs,
|
||||
} from "./predicates.js";
|
||||
|
||||
const contact: ContactData = {
|
||||
id: "c1",
|
||||
tags: ["vip", "premium"],
|
||||
channels: [{ type: "email", value: "test@example.com" }],
|
||||
status: "active",
|
||||
metadata: {
|
||||
country: "US",
|
||||
region: "Northeast",
|
||||
interests: "coding,gaming",
|
||||
},
|
||||
lastActivityAt: "2025-06-15T00:00:00.000Z" as unknown as Date,
|
||||
};
|
||||
|
||||
const evaluator = new SegmentEvaluator(async () => []);
|
||||
|
||||
describe("predicates", () => {
|
||||
it("hasTag produces a tags-contains rule", { timeout: 5000 }, () => {
|
||||
const rule = hasTag("vip");
|
||||
expect(rule.field).toBe("tags");
|
||||
expect(rule.operator).toBe("contains");
|
||||
expect(rule.value).toBe("vip");
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(evaluator.evaluateRule(contact, hasTag("basic"))).toBe(false);
|
||||
});
|
||||
|
||||
it("channelIs produces a channels.0.type-eq rule", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = channelIs("email");
|
||||
expect(rule.field).toBe("channels.0.type");
|
||||
expect(rule.operator).toBe("eq");
|
||||
expect(rule.value).toBe("email");
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(evaluator.evaluateRule(contact, channelIs("sms"))).toBe(false);
|
||||
});
|
||||
|
||||
it("statusIs produces a status-eq rule", { timeout: 5000 }, () => {
|
||||
const rule = statusIs("active");
|
||||
expect(rule.field).toBe("status");
|
||||
expect(rule.operator).toBe("eq");
|
||||
expect(rule.value).toBe("active");
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(evaluator.evaluateRule(contact, statusIs("inactive"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("lastActivityBefore produces a lastActivityAt-lt rule", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const date = new Date("2025-12-01");
|
||||
const rule = lastActivityBefore(date);
|
||||
expect(rule.field).toBe("lastActivityAt");
|
||||
expect(rule.operator).toBe("lt");
|
||||
expect(rule.value).toBe(date.toISOString());
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(
|
||||
evaluator.evaluateRule(
|
||||
contact,
|
||||
lastActivityBefore(new Date("2025-01-01"))
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lastActivityAfter produces a lastActivityAt-gt rule", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const date = new Date("2025-01-01");
|
||||
const rule = lastActivityAfter(date);
|
||||
expect(rule.field).toBe("lastActivityAt");
|
||||
expect(rule.operator).toBe("gt");
|
||||
expect(rule.value).toBe(date.toISOString());
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(
|
||||
evaluator.evaluateRule(
|
||||
contact,
|
||||
lastActivityAfter(new Date("2025-12-01"))
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("metadataEquals produces a metadata-field-eq rule", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = metadataEquals("country", "US");
|
||||
expect(rule.field).toBe("metadata.country");
|
||||
expect(rule.operator).toBe("eq");
|
||||
expect(rule.value).toBe("US");
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(
|
||||
evaluator.evaluateRule(contact, metadataEquals("country", "UK"))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("metadataContains produces a metadata-field-contains rule", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = metadataContains("interests", "coding");
|
||||
expect(rule.field).toBe("metadata.interests");
|
||||
expect(rule.operator).toBe("contains");
|
||||
expect(rule.value).toBe("coding");
|
||||
expect(evaluator.evaluateRule(contact, rule)).toBe(true);
|
||||
expect(
|
||||
evaluator.evaluateRule(
|
||||
contact,
|
||||
metadataContains("interests", "music")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SegmentRule } from "../types/segment.types.js";
|
||||
|
||||
/** Matches contacts whose `tags` array contains the given tag. */
|
||||
export function hasTag(tag: string): SegmentRule {
|
||||
return { field: "tags", operator: "contains", value: tag };
|
||||
}
|
||||
|
||||
/** Matches contacts whose first channel has the given type. */
|
||||
export function channelIs(type: string): SegmentRule {
|
||||
return { field: "channels.0.type", operator: "eq", value: type };
|
||||
}
|
||||
|
||||
/** Matches contacts whose `status` equals the given value. */
|
||||
export function statusIs(status: string): SegmentRule {
|
||||
return { field: "status", operator: "eq", value: status };
|
||||
}
|
||||
|
||||
/** Matches contacts whose `lastActivityAt` is before the given date (ISO compare). */
|
||||
export function lastActivityBefore(date: Date): SegmentRule {
|
||||
return {
|
||||
field: "lastActivityAt",
|
||||
operator: "lt",
|
||||
value: date.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Matches contacts whose `lastActivityAt` is after the given date (ISO compare). */
|
||||
export function lastActivityAfter(date: Date): SegmentRule {
|
||||
return {
|
||||
field: "lastActivityAt",
|
||||
operator: "gt",
|
||||
value: date.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Matches contacts where `metadata.<field>` equals the given value. */
|
||||
export function metadataEquals(field: string, value: unknown): SegmentRule {
|
||||
return { field: `metadata.${field}`, operator: "eq", value };
|
||||
}
|
||||
|
||||
/** Matches contacts where `metadata.<field>` contains the given value. */
|
||||
export function metadataContains(field: string, value: unknown): SegmentRule {
|
||||
return { field: `metadata.${field}`, operator: "contains", value };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` exposes the Zod validators that guard every public input
|
||||
boundary on the `@suaveplan/audience-rules` store API, plus the `parseOrThrow`
|
||||
helper that converts a `ZodError` into a typed `AudienceRulesError` with code
|
||||
`AUDIENCE_RULES.INVALID_INPUT`. Centralising the validators in one module
|
||||
keeps the constraint surface auditable and lets consumers reuse the same
|
||||
schemas (e.g. for HTTP-layer body parsing in a future `api-plugin-audience-rules`)
|
||||
without depending on internal helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- `segmentOperatorSchema` — Zod enum mirroring the 14 `SegmentOperator`
|
||||
values.
|
||||
- `logicalOpSchema` — Zod enum for `"and" | "or"`.
|
||||
- `refreshModeSchema` — Zod enum for `"realtime" | "scheduled" | "manual"`.
|
||||
- `segmentRuleSchema` — validates `{ field, operator, value?, logicalOp? }`.
|
||||
- `createSegmentInputSchema` — validates the full `CreateSegmentInput`.
|
||||
- `updateSegmentInputSchema` — validates the partial `UpdateSegmentInput`
|
||||
and rejects an empty patch (all fields undefined).
|
||||
- `parseOrThrow(schema, input, label)` — runs `safeParse`, returns the typed
|
||||
value on success, throws `AudienceRulesError` with `INVALID_INPUT` and a
|
||||
human-readable `path: message` summary on failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSegmentInputSchema,
|
||||
parseOrThrow,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
const input = parseOrThrow(
|
||||
createSegmentInputSchema,
|
||||
{ tenantId: "t1", name: "VIPs", rules: [], refreshMode: "manual" },
|
||||
"createSegment"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
parseOrThrow,
|
||||
segmentRuleSchema,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
try {
|
||||
parseOrThrow(
|
||||
segmentRuleSchema,
|
||||
{ field: "", operator: "eq", value: "x" },
|
||||
"segmentRule"
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AudienceRulesError &&
|
||||
err.code === AUDIENCE_RULES_CODES.INVALID_INPUT
|
||||
) {
|
||||
// err.cause is the original ZodError with the full issues array
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `segmentOperatorSchema: z.ZodEnum<SegmentOperator>`
|
||||
- `logicalOpSchema: z.ZodEnum<LogicalOp>`
|
||||
- `refreshModeSchema: z.ZodEnum<RefreshMode>`
|
||||
- `segmentRuleSchema: z.ZodObject<{...}>` — produces a `SegmentRule`.
|
||||
- `createSegmentInputSchema: z.ZodObject<{...}>` — produces a
|
||||
`CreateSegmentInput`.
|
||||
- `updateSegmentInputSchema: z.ZodObject<{...}>` — produces an
|
||||
`UpdateSegmentInput`; refines that at least one optional field is present.
|
||||
- `parseOrThrow<T>(schema: z.ZodType<T>, input: unknown, label: string): T` —
|
||||
returns parsed value, or throws `AudienceRulesError` with code
|
||||
`AUDIENCE_RULES.INVALID_INPUT` and the original `ZodError` preserved in
|
||||
`cause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Schemas use `z.string().min(1)` for identity-like fields (`tenantId`,
|
||||
`name`, `field`) so empty strings are rejected at the boundary.
|
||||
- The `updateSegmentInputSchema` `.refine` predicate prevents callers from
|
||||
shipping an empty patch — every `update` MUST change at least one field, or
|
||||
it is rejected with `INVALID_INPUT`.
|
||||
- The error message produced by `parseOrThrow` lists every issue as
|
||||
`path: message; path: message` so server logs and OTel span events surface
|
||||
every validation failure, not just the first.
|
||||
@@ -0,0 +1,85 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Zod schema for {@link SegmentOperator}. */
|
||||
export const segmentOperatorSchema = z.enum([
|
||||
"eq",
|
||||
"neq",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"in",
|
||||
"not_in",
|
||||
"exists",
|
||||
"not_exists",
|
||||
"starts_with",
|
||||
"ends_with",
|
||||
]);
|
||||
|
||||
/** Zod schema for {@link LogicalOp}. */
|
||||
export const logicalOpSchema = z.enum(["and", "or"]);
|
||||
|
||||
/** Zod schema for {@link RefreshMode}. */
|
||||
export const refreshModeSchema = z.enum(["realtime", "scheduled", "manual"]);
|
||||
|
||||
/** Zod schema for {@link SegmentRule}. */
|
||||
export const segmentRuleSchema = z.object({
|
||||
field: z.string().min(1),
|
||||
operator: segmentOperatorSchema,
|
||||
value: z.unknown().optional(),
|
||||
logicalOp: logicalOpSchema.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link CreateSegmentInput}. */
|
||||
export const createSegmentInputSchema = z.object({
|
||||
tenantId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
rules: z.array(segmentRuleSchema),
|
||||
refreshMode: refreshModeSchema,
|
||||
});
|
||||
|
||||
/** Zod schema for {@link UpdateSegmentInput}. */
|
||||
export const updateSegmentInputSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
rules: z.array(segmentRuleSchema).optional(),
|
||||
refreshMode: refreshModeSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(input) =>
|
||||
input.name !== undefined ||
|
||||
input.description !== undefined ||
|
||||
input.rules !== undefined ||
|
||||
input.refreshMode !== undefined,
|
||||
{ message: "at least one field is required" }
|
||||
);
|
||||
|
||||
/**
|
||||
* Parses `input` against `schema` and returns the validated value. Throws an
|
||||
* {@link AudienceRulesError} with code `AUDIENCE_RULES.INVALID_INPUT` (never a
|
||||
* raw `ZodError`) on failure, preserving the issues list in `cause`.
|
||||
*/
|
||||
export function parseOrThrow<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
label: string
|
||||
): T {
|
||||
const result = schema.safeParse(input);
|
||||
if (result.success) return result.data;
|
||||
const issues = result.error.issues
|
||||
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.INVALID_INPUT,
|
||||
`${label} input failed validation: ${issues}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# segment-evaluator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`segment-evaluator.ts` provides the pure-function rule engine that decides,
|
||||
for a given `Segment` and a tenant's `ContactData[]`, which contacts match.
|
||||
It supports fourteen comparison operators, AND/OR logical chaining, and
|
||||
dot-notation field resolution including numeric array indices (e.g.
|
||||
`channels.0.type`). It is intentionally storage-agnostic — contact data is
|
||||
supplied by an injected `ContactDataProvider`, so the evaluator runs equally
|
||||
well against an in-memory test array, a paginated database cursor, or a
|
||||
remote API.
|
||||
|
||||
## Features
|
||||
|
||||
- 14 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`,
|
||||
`not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`,
|
||||
`ends_with`.
|
||||
- AND/OR logical chaining via `logicalOp` on each subsequent rule (default
|
||||
`"and"`; first rule's `logicalOp` is ignored).
|
||||
- Dot-notation field resolution with numeric array index access.
|
||||
- Empty-rules segment matches every contact.
|
||||
- `evaluateRule` exposed for ad-hoc rule testing against a single contact.
|
||||
- Unknown operator raises `AudienceRulesError` with
|
||||
`AUDIENCE_RULES.UNKNOWN_OPERATOR` — the discriminated `SegmentOperator`
|
||||
union makes this branch reachable only via a deliberate cast.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SegmentEvaluator } from "@suaveplan/audience-rules";
|
||||
|
||||
const provider = async () => [
|
||||
{ id: "c1", tags: ["vip"], status: "active" },
|
||||
{ id: "c2", tags: ["basic"], status: "active" },
|
||||
];
|
||||
|
||||
const evaluator = new SegmentEvaluator(provider);
|
||||
const result = await evaluator.evaluate({
|
||||
id: "seg1",
|
||||
tenantId: "t1",
|
||||
name: "VIPs",
|
||||
rules: [{ field: "tags", operator: "contains", value: "vip" }],
|
||||
refreshMode: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
// result.contactIds === ["c1"]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SegmentEvaluator } from "@suaveplan/audience-rules";
|
||||
|
||||
const provider = async () => [
|
||||
{
|
||||
id: "c1",
|
||||
status: "active",
|
||||
metadata: { country: "US", score: 90 },
|
||||
channels: [{ type: "email", value: "a@b.com" }],
|
||||
},
|
||||
];
|
||||
const evaluator = new SegmentEvaluator(provider);
|
||||
|
||||
// "active AND US, OR score >= 95"
|
||||
const result = await evaluator.evaluate({
|
||||
id: "seg",
|
||||
tenantId: "t1",
|
||||
name: "complex",
|
||||
refreshMode: "manual",
|
||||
rules: [
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "eq",
|
||||
value: "US",
|
||||
logicalOp: "and",
|
||||
},
|
||||
{
|
||||
field: "metadata.score",
|
||||
operator: "gte",
|
||||
value: 95,
|
||||
logicalOp: "or",
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type ContactDataProvider = (tenantId: string) => Promise<readonly ContactData[]>`
|
||||
- `class SegmentEvaluator` — constructor `(provider: ContactDataProvider)`.
|
||||
- `evaluator.evaluate(segment: Segment): Promise<SegmentEvaluationResult>` —
|
||||
fetches contacts via the provider, evaluates every rule against every
|
||||
contact, returns matching IDs + `total` + `evaluatedAt` + `matchRate`.
|
||||
- `evaluator.evaluateRule(contact: ContactData, rule: SegmentRule): boolean`
|
||||
— pure helper; resolves the dot-notation field and applies the operator.
|
||||
- Throws `AudienceRulesError` with `AUDIENCE_RULES.UNKNOWN_OPERATOR` when an
|
||||
operator outside the closed `SegmentOperator` union is supplied.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `resolveField` returns `undefined` for any missing intermediate path
|
||||
segment, so `not_exists` correctly matches deeply-nested missing fields.
|
||||
- Comparison operators apply unchecked casts (`fieldValue as number`) — this
|
||||
matches the upstream behaviour and lets callers compare ISO date strings
|
||||
lexicographically, which is what the predicate helpers `lastActivityBefore`
|
||||
/ `lastActivityAfter` rely on.
|
||||
- `matchesRules` is left-to-right and does not short-circuit aggressively;
|
||||
it always evaluates every rule. This keeps the algorithm simple and
|
||||
deterministic; for very large rule lists the cost is `O(rules * contacts)`.
|
||||
- The evaluator is stateless past the injected provider, so the same instance
|
||||
can serve concurrent segment evaluations safely.
|
||||
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
ContactData,
|
||||
Segment,
|
||||
SegmentRule,
|
||||
} from "../types/segment.types.js";
|
||||
import { SegmentEvaluator } from "./segment-evaluator.js";
|
||||
|
||||
const makeContact = (overrides: Partial<ContactData> = {}): ContactData => ({
|
||||
id: "c1",
|
||||
tags: ["vip"],
|
||||
channels: [{ type: "email", value: "a@b.com" }],
|
||||
status: "active",
|
||||
metadata: { country: "US", score: 85 },
|
||||
lastActivityAt: new Date("2025-06-01"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSegment = (rules: readonly SegmentRule[]): Segment => ({
|
||||
id: "seg1",
|
||||
tenantId: "tenant1",
|
||||
name: "Test",
|
||||
rules,
|
||||
refreshMode: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const provider = async (): Promise<readonly ContactData[]> => [
|
||||
makeContact({
|
||||
id: "c1",
|
||||
status: "active",
|
||||
tags: ["vip"],
|
||||
metadata: { country: "US", score: 85 },
|
||||
}),
|
||||
makeContact({
|
||||
id: "c2",
|
||||
status: "inactive",
|
||||
tags: ["basic"],
|
||||
metadata: { country: "UK", score: 40 },
|
||||
}),
|
||||
makeContact({
|
||||
id: "c3",
|
||||
status: "active",
|
||||
tags: ["vip", "premium"],
|
||||
metadata: { country: "US", score: 95 },
|
||||
}),
|
||||
];
|
||||
|
||||
describe("SegmentEvaluator", () => {
|
||||
const evaluator = new SegmentEvaluator(provider);
|
||||
|
||||
describe("evaluate", () => {
|
||||
it("applies every operator with the documented boolean result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Single comprehensive test exercising all 14 operators against
|
||||
// a known contact / rule pair via evaluateRule.
|
||||
const c = makeContact();
|
||||
const cases: [SegmentRule, boolean][] = [
|
||||
[{ field: "status", operator: "eq", value: "active" }, true],
|
||||
[{ field: "status", operator: "neq", value: "active" }, false],
|
||||
[{ field: "metadata.score", operator: "gt", value: 80 }, true],
|
||||
[{ field: "metadata.score", operator: "gte", value: 85 }, true],
|
||||
[{ field: "metadata.score", operator: "lt", value: 90 }, true],
|
||||
[{ field: "metadata.score", operator: "lte", value: 85 }, true],
|
||||
[{ field: "tags", operator: "contains", value: "vip" }, true],
|
||||
[
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "contains",
|
||||
value: "U",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{ field: "tags", operator: "not_contains", value: "basic" },
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "status",
|
||||
operator: "in",
|
||||
value: ["active", "pending"],
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "status",
|
||||
operator: "not_in",
|
||||
value: ["inactive"],
|
||||
},
|
||||
true,
|
||||
],
|
||||
[{ field: "metadata.country", operator: "exists" }, true],
|
||||
[{ field: "metadata.missing", operator: "not_exists" }, true],
|
||||
[
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "starts_with",
|
||||
value: "U",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "ends_with",
|
||||
value: "S",
|
||||
},
|
||||
true,
|
||||
],
|
||||
];
|
||||
for (const [rule, expected] of cases) {
|
||||
expect(evaluator.evaluateRule(c, rule)).toBe(expected);
|
||||
}
|
||||
// contains with non-string non-array field is false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "contains",
|
||||
value: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
// in with non-array rule value is false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "in",
|
||||
value: "active",
|
||||
})
|
||||
).toBe(false);
|
||||
// not_in with non-array rule value is false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "not_in",
|
||||
value: "active",
|
||||
})
|
||||
).toBe(false);
|
||||
// starts_with against non-string field
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "starts_with",
|
||||
value: "8",
|
||||
})
|
||||
).toBe(false);
|
||||
// ends_with against non-string field
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "ends_with",
|
||||
value: "5",
|
||||
})
|
||||
).toBe(false);
|
||||
// not_exists against existing
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "not_exists",
|
||||
})
|
||||
).toBe(false);
|
||||
// exists against missing
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.missing",
|
||||
operator: "exists",
|
||||
})
|
||||
).toBe(false);
|
||||
// gt false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "gt",
|
||||
value: 90,
|
||||
})
|
||||
).toBe(false);
|
||||
// gte false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "gte",
|
||||
value: 86,
|
||||
})
|
||||
).toBe(false);
|
||||
// lt false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "lt",
|
||||
value: 80,
|
||||
})
|
||||
).toBe(false);
|
||||
// lte false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.score",
|
||||
operator: "lte",
|
||||
value: 84,
|
||||
})
|
||||
).toBe(false);
|
||||
// eq false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "eq",
|
||||
value: "x",
|
||||
})
|
||||
).toBe(false);
|
||||
// neq true
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "neq",
|
||||
value: "x",
|
||||
})
|
||||
).toBe(true);
|
||||
// not_contains false (contains)
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "tags",
|
||||
operator: "not_contains",
|
||||
value: "vip",
|
||||
})
|
||||
).toBe(false);
|
||||
// in false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "in",
|
||||
value: ["x"],
|
||||
})
|
||||
).toBe(false);
|
||||
// not_in false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "not_in",
|
||||
value: ["active"],
|
||||
})
|
||||
).toBe(false);
|
||||
// starts_with false (string mismatch)
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.country",
|
||||
operator: "starts_with",
|
||||
value: "X",
|
||||
})
|
||||
).toBe(false);
|
||||
// ends_with false
|
||||
expect(
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "metadata.country",
|
||||
operator: "ends_with",
|
||||
value: "X",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("raises UNKNOWN_OPERATOR for an unrecognised operator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = makeContact();
|
||||
let caught: unknown;
|
||||
try {
|
||||
evaluator.evaluateRule(c, {
|
||||
field: "status",
|
||||
operator: "unknown" as never,
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudienceRulesError);
|
||||
expect((caught as AudienceRulesError).code).toBe(
|
||||
AUDIENCE_RULES_CODES.UNKNOWN_OPERATOR
|
||||
);
|
||||
});
|
||||
|
||||
it("narrows the match set when rules are AND-combined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const segment = makeSegment([
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "eq",
|
||||
value: "US",
|
||||
logicalOp: "and",
|
||||
},
|
||||
]);
|
||||
const result = await evaluator.evaluate(segment);
|
||||
expect(result.contactIds).toEqual(["c1", "c3"]);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.matchRate).toBeCloseTo(2 / 3);
|
||||
expect(result.evaluatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("widens the match set when rules are OR-combined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const segment = makeSegment([
|
||||
{ field: "status", operator: "eq", value: "inactive" },
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "eq",
|
||||
value: "US",
|
||||
logicalOp: "or",
|
||||
},
|
||||
]);
|
||||
const result = await evaluator.evaluate(segment);
|
||||
expect(result.contactIds).toEqual(["c1", "c2", "c3"]);
|
||||
});
|
||||
|
||||
it("defaults to AND when a subsequent rule omits logicalOp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// rule[1] has no `logicalOp` → exercises `?? "and"` default branch
|
||||
const segment = makeSegment([
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "eq",
|
||||
value: "US",
|
||||
},
|
||||
]);
|
||||
const result = await evaluator.evaluate(segment);
|
||||
expect(result.contactIds).toEqual(["c1", "c3"]);
|
||||
});
|
||||
|
||||
it("matches every contact when the rule list is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await evaluator.evaluate(makeSegment([]));
|
||||
expect(result.total).toBe(3);
|
||||
});
|
||||
|
||||
it("returns matchRate of 0 when contact list is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const empty = new SegmentEvaluator(async () => []);
|
||||
const result = await empty.evaluate(
|
||||
makeSegment([
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
])
|
||||
);
|
||||
expect(result.matchRate).toBe(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves dot-notation paths through nested objects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
evaluator.evaluateRule(makeContact(), {
|
||||
field: "metadata.country",
|
||||
operator: "eq",
|
||||
value: "US",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves numeric indices into array fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
evaluator.evaluateRule(makeContact(), {
|
||||
field: "channels.0.type",
|
||||
operator: "eq",
|
||||
value: "email",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined for missing nested paths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
evaluator.evaluateRule(makeContact(), {
|
||||
field: "a.b.c.d",
|
||||
operator: "eq",
|
||||
value: "x",
|
||||
})
|
||||
).toBe(false);
|
||||
// exists false path
|
||||
expect(
|
||||
evaluator.evaluateRule(makeContact(), {
|
||||
field: "a.b.c.d",
|
||||
operator: "exists",
|
||||
})
|
||||
).toBe(false);
|
||||
// path traversal hitting a primitive intermediate
|
||||
expect(
|
||||
evaluator.evaluateRule(makeContact(), {
|
||||
field: "status.something",
|
||||
operator: "eq",
|
||||
value: "x",
|
||||
})
|
||||
).toBe(false);
|
||||
// path traversal where intermediate is null
|
||||
expect(
|
||||
evaluator.evaluateRule(
|
||||
makeContact({
|
||||
metadata: { country: null as unknown as string },
|
||||
}),
|
||||
{
|
||||
field: "metadata.country.value",
|
||||
operator: "eq",
|
||||
value: "x",
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
// non-numeric segment against an array drops to object lookup
|
||||
expect(
|
||||
evaluator.evaluateRule(makeContact(), {
|
||||
field: "tags.notanumber",
|
||||
operator: "exists",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
ContactData,
|
||||
Segment,
|
||||
SegmentEvaluationResult,
|
||||
SegmentRule,
|
||||
} from "../types/segment.types.js";
|
||||
|
||||
/** Async provider returning contacts for a tenant. */
|
||||
export type ContactDataProvider = (
|
||||
tenantId: string
|
||||
) => Promise<readonly ContactData[]>;
|
||||
|
||||
/**
|
||||
* Resolves a dot-notation field path against an object. Supports numeric
|
||||
* indices for array elements (e.g. `channels.0.type`). Returns `undefined` for
|
||||
* missing intermediate paths.
|
||||
*/
|
||||
function resolveField(root: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let current: unknown = root;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== "object") return undefined;
|
||||
const index = Number(part);
|
||||
if (Array.isArray(current) && !Number.isNaN(index)) {
|
||||
current = current[index];
|
||||
} else {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Evaluates segment rules against contact data to produce matching IDs. */
|
||||
export class SegmentEvaluator {
|
||||
private readonly provider: ContactDataProvider;
|
||||
|
||||
public constructor(provider: ContactDataProvider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
/** Evaluate all rules in `segment` against the tenant's contacts. */
|
||||
public async evaluate(segment: Segment): Promise<SegmentEvaluationResult> {
|
||||
const contacts = await this.provider(segment.tenantId);
|
||||
const matchingIds: string[] = [];
|
||||
for (const contact of contacts) {
|
||||
if (this.matchesRules(contact, segment.rules)) {
|
||||
matchingIds.push(contact.id);
|
||||
}
|
||||
}
|
||||
return {
|
||||
contactIds: matchingIds,
|
||||
total: matchingIds.length,
|
||||
evaluatedAt: new Date(),
|
||||
matchRate:
|
||||
contacts.length > 0 ? matchingIds.length / contacts.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Evaluate a single rule against a single contact. */
|
||||
public evaluateRule(contact: ContactData, rule: SegmentRule): boolean {
|
||||
const fieldValue = resolveField(
|
||||
contact as unknown as Record<string, unknown>,
|
||||
rule.field
|
||||
);
|
||||
return this.applyOperator(fieldValue, rule.operator, rule.value);
|
||||
}
|
||||
|
||||
private matchesRules(
|
||||
contact: ContactData,
|
||||
rules: readonly SegmentRule[]
|
||||
): boolean {
|
||||
const first = rules[0];
|
||||
if (first === undefined) return true;
|
||||
let result = this.evaluateRule(contact, first);
|
||||
for (let i = 1; i < rules.length; i++) {
|
||||
const rule = rules[i] as SegmentRule;
|
||||
const ruleResult = this.evaluateRule(contact, rule);
|
||||
const op = rule.logicalOp ?? "and";
|
||||
result = op === "or" ? result || ruleResult : result && ruleResult;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private applyOperator(
|
||||
fieldValue: unknown,
|
||||
operator: string,
|
||||
ruleValue: unknown
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return fieldValue === ruleValue;
|
||||
case "neq":
|
||||
return fieldValue !== ruleValue;
|
||||
case "gt":
|
||||
return (fieldValue as number) > (ruleValue as number);
|
||||
case "gte":
|
||||
return (fieldValue as number) >= (ruleValue as number);
|
||||
case "lt":
|
||||
return (fieldValue as number) < (ruleValue as number);
|
||||
case "lte":
|
||||
return (fieldValue as number) <= (ruleValue as number);
|
||||
case "contains":
|
||||
return this.checkContains(fieldValue, ruleValue);
|
||||
case "not_contains":
|
||||
return !this.checkContains(fieldValue, ruleValue);
|
||||
case "in":
|
||||
return (
|
||||
Array.isArray(ruleValue) && ruleValue.includes(fieldValue)
|
||||
);
|
||||
case "not_in":
|
||||
return (
|
||||
Array.isArray(ruleValue) && !ruleValue.includes(fieldValue)
|
||||
);
|
||||
case "exists":
|
||||
return fieldValue !== undefined && fieldValue !== null;
|
||||
case "not_exists":
|
||||
return fieldValue === undefined || fieldValue === null;
|
||||
case "starts_with":
|
||||
return (
|
||||
typeof fieldValue === "string" &&
|
||||
typeof ruleValue === "string" &&
|
||||
fieldValue.startsWith(ruleValue)
|
||||
);
|
||||
case "ends_with":
|
||||
return (
|
||||
typeof fieldValue === "string" &&
|
||||
typeof ruleValue === "string" &&
|
||||
fieldValue.endsWith(ruleValue)
|
||||
);
|
||||
default:
|
||||
throw new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.UNKNOWN_OPERATOR,
|
||||
`unknown SegmentRule operator: ${operator}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private checkContains(fieldValue: unknown, ruleValue: unknown): boolean {
|
||||
if (Array.isArray(fieldValue)) return fieldValue.includes(ruleValue);
|
||||
if (typeof fieldValue === "string" && typeof ruleValue === "string") {
|
||||
return fieldValue.includes(ruleValue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# segment-refresher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`segment-refresher.ts` orchestrates re-evaluation of one or many segments
|
||||
and persists the resulting `contactIds` back to the `SegmentStore` cache. It
|
||||
exposes a typed `EventBus<SegmentRefresherEventMap>` so downstream consumers
|
||||
(broadcast schedulers, real-time dashboards, OTel exporters) can subscribe to
|
||||
`segment:refreshed` without coupling to the refresher's internals. The
|
||||
refresher is intentionally driver-agnostic — it does not own a timer; the
|
||||
consumer drives `refresh` / `refreshAll` from whatever cadence policy
|
||||
(cron, push, manual button) it prefers.
|
||||
|
||||
## Features
|
||||
|
||||
- `refresh(segmentId)` — single-segment re-evaluation; returns `undefined`
|
||||
for an unknown ID without emitting.
|
||||
- `refreshAll(tenantId)` — multi-segment fan-out; emits once per refreshed
|
||||
segment.
|
||||
- Typed `EventBus<SegmentRefresherEventMap>` — `events.on("segment:refreshed",
|
||||
handler)` receives `{ segmentId, result }`.
|
||||
- Cache write through `store.updateCache(segmentId, contactIds)` on every
|
||||
refresh, updating `cachedContactIds`, `cachedCount`, `lastRefreshedAt`,
|
||||
`updatedAt`.
|
||||
- Pure composition of `SegmentEvaluator` + `SegmentStore` — both are
|
||||
constructor-injected so test doubles wire in cleanly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemorySegmentStore,
|
||||
SegmentEvaluator,
|
||||
SegmentRefresher,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
const store = new InMemorySegmentStore();
|
||||
const evaluator = new SegmentEvaluator(async () => [
|
||||
{ id: "c1", tags: ["vip"] },
|
||||
]);
|
||||
const refresher = new SegmentRefresher(store, evaluator);
|
||||
|
||||
const segment = await store.create({
|
||||
tenantId: "t1",
|
||||
name: "VIPs",
|
||||
rules: [{ field: "tags", operator: "contains", value: "vip" }],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
|
||||
const result = await refresher.refresh(segment.id);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemorySegmentStore,
|
||||
SegmentEvaluator,
|
||||
SegmentRefresher,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
const refresher = new SegmentRefresher(
|
||||
new InMemorySegmentStore(),
|
||||
new SegmentEvaluator(async () => [])
|
||||
);
|
||||
|
||||
refresher.events.on("segment:refreshed", ({ segmentId, result }) => {
|
||||
// Forward to OTel, dashboard, downstream pub/sub, etc.
|
||||
console.log(
|
||||
`refreshed ${segmentId}: ${result.total} (${(result.matchRate * 100).toFixed(1)}%)`
|
||||
);
|
||||
});
|
||||
|
||||
await refresher.refreshAll("tenant-1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SegmentRefresherEventMap` — `"segment:refreshed": { segmentId, result }`.
|
||||
- `class SegmentRefresher` — constructor `(store: SegmentStore, evaluator: SegmentEvaluator)`.
|
||||
- `refresher.events: EventBus<SegmentRefresherEventMap>` — typed event bus
|
||||
from `@suaveplan/events`; `on` / `off` / `emit` / `use` / `clear`.
|
||||
- `refresher.refresh(segmentId: string): Promise<SegmentEvaluationResult | undefined>`
|
||||
- `refresher.refreshAll(tenantId: string): Promise<Map<string, SegmentEvaluationResult>>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The migration swaps upstream's untyped `EventEmitter` for the suaveplan
|
||||
`EventBus<TMap>`. The consumer API (`on("segment:refreshed", handler)`)
|
||||
is preserved verbatim; only the type signature of `handler` becomes
|
||||
precise.
|
||||
- `refresh` returns `undefined` for a missing segment because the absence
|
||||
is a normal (not exceptional) outcome — the consumer typically retries
|
||||
with a corrected ID or surfaces a `404` at the API layer.
|
||||
- `refreshAll` iterates sequentially; concurrent fan-out is intentionally
|
||||
left to the caller (Promise.all over selected segment IDs) so the
|
||||
refresher does not need to own a concurrency policy.
|
||||
- The `events` field is publicly readable but the `EventBus` is mutable —
|
||||
consumers are expected to subscribe, not replace.
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SegmentEvaluator } from "../segment-evaluator/segment-evaluator.js";
|
||||
import { InMemorySegmentStore } from "../store/store.js";
|
||||
import type { ContactData } from "../types/segment.types.js";
|
||||
import { SegmentRefresher } from "./segment-refresher.js";
|
||||
|
||||
const CONTACTS: readonly ContactData[] = [
|
||||
{ id: "c1", tags: ["vip"], status: "active", metadata: { country: "US" } },
|
||||
{
|
||||
id: "c2",
|
||||
tags: ["basic"],
|
||||
status: "inactive",
|
||||
metadata: { country: "UK" },
|
||||
},
|
||||
{ id: "c3", tags: ["vip"], status: "active", metadata: { country: "US" } },
|
||||
];
|
||||
|
||||
const setup = async () => {
|
||||
const store = new InMemorySegmentStore();
|
||||
const evaluator = new SegmentEvaluator(async () => CONTACTS);
|
||||
const refresher = new SegmentRefresher(store, evaluator);
|
||||
const segment = await store.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Active US",
|
||||
rules: [
|
||||
{ field: "status", operator: "eq", value: "active" },
|
||||
{
|
||||
field: "metadata.country",
|
||||
operator: "eq",
|
||||
value: "US",
|
||||
logicalOp: "and",
|
||||
},
|
||||
],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
return { store, evaluator, refresher, segment };
|
||||
};
|
||||
|
||||
describe("SegmentRefresher", () => {
|
||||
it("refresh updates the store cache and emits segment:refreshed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { store, refresher, segment } = await setup();
|
||||
let observed:
|
||||
| { segmentId: string; result: { total: number } }
|
||||
| undefined;
|
||||
refresher.events.on("segment:refreshed", (payload) => {
|
||||
observed = payload;
|
||||
});
|
||||
const result = await refresher.refresh(segment.id);
|
||||
expect(result?.contactIds).toEqual(["c1", "c3"]);
|
||||
expect(result?.total).toBe(2);
|
||||
expect(result?.matchRate).toBeCloseTo(2 / 3);
|
||||
|
||||
const updated = await store.getById(segment.id);
|
||||
expect(updated?.cachedContactIds).toEqual(["c1", "c3"]);
|
||||
expect(updated?.cachedCount).toBe(2);
|
||||
expect(updated?.lastRefreshedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(observed?.segmentId).toBe(segment.id);
|
||||
expect(observed?.result.total).toBe(2);
|
||||
});
|
||||
|
||||
it("refresh returns undefined for an unknown segment ID", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { refresher } = await setup();
|
||||
let emitted = false;
|
||||
refresher.events.on("segment:refreshed", () => {
|
||||
emitted = true;
|
||||
});
|
||||
const result = await refresher.refresh("missing");
|
||||
expect(result).toBeUndefined();
|
||||
expect(emitted).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshAll emits segment:refreshed once per refreshed segment", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { store, refresher } = await setup();
|
||||
await store.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "VIP",
|
||||
rules: [{ field: "tags", operator: "contains", value: "vip" }],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
await store.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Inactive",
|
||||
rules: [{ field: "status", operator: "eq", value: "inactive" }],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
let count = 0;
|
||||
refresher.events.on("segment:refreshed", () => {
|
||||
count += 1;
|
||||
});
|
||||
const results = await refresher.refreshAll("tenant-1");
|
||||
expect(results.size).toBe(3);
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("refreshAll returns an empty map for a tenant with no segments", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { refresher } = await setup();
|
||||
const results = await refresher.refreshAll("unknown-tenant");
|
||||
expect(results.size).toBe(0);
|
||||
});
|
||||
|
||||
it("refreshAll updates the cache for every segment", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { store, refresher, segment } = await setup();
|
||||
const segment2 = await store.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "All",
|
||||
rules: [],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
await refresher.refreshAll("tenant-1");
|
||||
const u1 = await store.getById(segment.id);
|
||||
const u2 = await store.getById(segment2.id);
|
||||
expect(u1?.cachedCount).toBe(2);
|
||||
expect(u2?.cachedCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SegmentEvaluator } from "../segment-evaluator/segment-evaluator.js";
|
||||
import type { SegmentStore } from "../store/store.js";
|
||||
import type { SegmentEvaluationResult } from "../types/segment.types.js";
|
||||
|
||||
/** Payload emitted on `segment:refreshed`. */
|
||||
export interface SegmentRefreshedPayload {
|
||||
readonly segmentId: string;
|
||||
readonly result: SegmentEvaluationResult;
|
||||
}
|
||||
|
||||
/** Event map carried by {@link SegmentRefresher.events}. */
|
||||
export type SegmentRefresherEventMap = {
|
||||
/** Emitted once per successful refresh. */
|
||||
"segment:refreshed": SegmentRefreshedPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles re-evaluation of segments and updating their cached results. Emits
|
||||
* `segment:refreshed` on its typed `EventBus<SegmentRefresherEventMap>` after
|
||||
* each successful refresh.
|
||||
*/
|
||||
export class SegmentRefresher {
|
||||
private readonly store: SegmentStore;
|
||||
private readonly evaluator: SegmentEvaluator;
|
||||
/** Typed event bus surfacing refresh notifications. */
|
||||
public readonly events = new EventBus<SegmentRefresherEventMap>();
|
||||
|
||||
public constructor(store: SegmentStore, evaluator: SegmentEvaluator) {
|
||||
this.store = store;
|
||||
this.evaluator = evaluator;
|
||||
}
|
||||
|
||||
/** Refresh a single segment by ID; returns `undefined` for an unknown ID. */
|
||||
public async refresh(
|
||||
segmentId: string
|
||||
): Promise<SegmentEvaluationResult | undefined> {
|
||||
const segment = await this.store.getById(segmentId);
|
||||
if (segment === undefined) return undefined;
|
||||
const result = await this.evaluator.evaluate(segment);
|
||||
await this.store.updateCache(segmentId, result.contactIds);
|
||||
this.events.emit("segment:refreshed", { segmentId, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Refresh every segment owned by a tenant. */
|
||||
public async refreshAll(
|
||||
tenantId: string
|
||||
): Promise<Map<string, SegmentEvaluationResult>> {
|
||||
const segments = await this.store.list(tenantId);
|
||||
const results = new Map<string, SegmentEvaluationResult>();
|
||||
for (const segment of segments) {
|
||||
const result = await this.evaluator.evaluate(segment);
|
||||
await this.store.updateCache(segment.id, result.contactIds);
|
||||
this.events.emit("segment:refreshed", {
|
||||
segmentId: segment.id,
|
||||
result,
|
||||
});
|
||||
results.set(segment.id, result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
93
packages/commerce/contacts/audience-rules/src/store/store.md
Normal file
93
packages/commerce/contacts/audience-rules/src/store/store.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`store.ts` defines the `SegmentStore` interface that abstracts segment
|
||||
persistence behind a six-method contract (`create`, `getById`, `update`,
|
||||
`delete`, `list`, `updateCache`) and ships `InMemorySegmentStore` — a
|
||||
single-process, map-backed implementation suitable for development, tests,
|
||||
and lightweight production deployments. The in-memory store is built around
|
||||
injectable `Clock` and `IdGenerator` primitives from `@suaveplan/types`, so
|
||||
production paths never call `Date.now()` / `new Date()` directly or mutate a
|
||||
module-scope counter.
|
||||
|
||||
## Features
|
||||
|
||||
- `SegmentStore` interface — six async methods, all returning the persisted
|
||||
`Segment` (or `undefined` when the identifier does not exist).
|
||||
- `InMemorySegmentStore` — single-process map-backed implementation.
|
||||
- `InMemorySegmentStoreOptions { clock?, idGenerator? }` — both injection
|
||||
points are optional with sensible defaults.
|
||||
- Zod boundary on `create` and `update` — invalid input raises
|
||||
`AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` (never a raw
|
||||
`ZodError`).
|
||||
- Multi-tenant isolation on `list(tenantId)` — segments owned by other
|
||||
tenants are invisible.
|
||||
- `updateCache(id, contactIds)` updates `cachedContactIds`, `cachedCount`,
|
||||
`lastRefreshedAt`, and `updatedAt` atomically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { InMemorySegmentStore } from "@suaveplan/audience-rules";
|
||||
|
||||
const store = new InMemorySegmentStore();
|
||||
const segment = await store.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Active VIPs",
|
||||
rules: [{ field: "tags", operator: "contains", value: "vip" }],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { InMemorySegmentStore } from "@suaveplan/audience-rules";
|
||||
|
||||
// Deterministic store for tests: pinned clock + monotonic ID generator
|
||||
let n = 0;
|
||||
const store = new InMemorySegmentStore({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => {
|
||||
n += 1;
|
||||
return `test_seg_${n}`;
|
||||
},
|
||||
});
|
||||
|
||||
const a = await store.create({
|
||||
tenantId: "t1",
|
||||
name: "A",
|
||||
rules: [],
|
||||
refreshMode: "manual",
|
||||
});
|
||||
// a.id === "test_seg_1"; a.createdAt.getTime() === 1_700_000_000_000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SegmentStore` — see Features.
|
||||
- `interface InMemorySegmentStoreOptions` — `clock?: Clock`,
|
||||
`idGenerator?: IdGenerator` (both from `@suaveplan/types`).
|
||||
- `class InMemorySegmentStore implements SegmentStore` — constructor accepts
|
||||
`InMemorySegmentStoreOptions`. Throws `AudienceRulesError` with
|
||||
`AUDIENCE_RULES.INVALID_INPUT` on Zod failure in `create` / `update`, and
|
||||
on `list` with an empty `tenantId`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `IdGenerator` is a per-instance closure, so two separate stores
|
||||
in the same process produce independent ID sequences. This matches the
|
||||
`@suaveplan/contacts` `InMemoryContactStore` convention.
|
||||
- `create` and `update` validate input with `parseOrThrow` before mutating
|
||||
the internal map — failure leaves the store untouched.
|
||||
- `list` takes an `O(n)` scan; the package is a development-tier store and
|
||||
multi-tenant filtering happens in-memory. Production stores backed by a
|
||||
database can index on `tenantId` directly.
|
||||
- Every mutation updates `updatedAt` via the injected clock, so OTel
|
||||
histograms and snapshot diffs in consumers stay deterministic under
|
||||
`FakeClock`.
|
||||
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { CreateSegmentInput } from "../types/segment.types.js";
|
||||
import { InMemorySegmentStore } from "./store.js";
|
||||
|
||||
const VALID_INPUT: CreateSegmentInput = {
|
||||
tenantId: "tenant-1",
|
||||
name: "VIPs",
|
||||
rules: [{ field: "tags", operator: "contains", value: "vip" }],
|
||||
refreshMode: "manual",
|
||||
};
|
||||
|
||||
describe("InMemorySegmentStore", () => {
|
||||
let store: InMemorySegmentStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemorySegmentStore();
|
||||
});
|
||||
|
||||
it("creates and reads back a segment", { timeout: 5000 }, async () => {
|
||||
const created = await store.create(VALID_INPUT);
|
||||
expect(created.id).toMatch(/^seg_\d+$/);
|
||||
expect(created.tenantId).toBe("tenant-1");
|
||||
expect(created.name).toBe("VIPs");
|
||||
const fetched = await store.getById(created.id);
|
||||
expect(fetched).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown getById", { timeout: 5000 }, async () => {
|
||||
expect(await store.getById("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates an existing segment", { timeout: 5000 }, async () => {
|
||||
const created = await store.create(VALID_INPUT);
|
||||
const updated = await store.update(created.id, {
|
||||
name: "VIPs (renamed)",
|
||||
description: "everyone tagged vip",
|
||||
rules: [{ field: "tags", operator: "contains", value: "vip" }],
|
||||
refreshMode: "realtime",
|
||||
});
|
||||
expect(updated?.name).toBe("VIPs (renamed)");
|
||||
expect(updated?.description).toBe("everyone tagged vip");
|
||||
expect(updated?.refreshMode).toBe("realtime");
|
||||
});
|
||||
|
||||
it("update returns undefined for unknown id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await store.update("missing", { name: "x" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deletes a segment", { timeout: 5000 }, async () => {
|
||||
const created = await store.create(VALID_INPUT);
|
||||
expect(await store.delete(created.id)).toBe(true);
|
||||
expect(await store.delete(created.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("list returns only segments for the requested tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await store.create({ ...VALID_INPUT, tenantId: "tenant-a" });
|
||||
await store.create({ ...VALID_INPUT, tenantId: "tenant-a" });
|
||||
await store.create({ ...VALID_INPUT, tenantId: "tenant-b" });
|
||||
const a = await store.list("tenant-a");
|
||||
const b = await store.list("tenant-b");
|
||||
expect(a).toHaveLength(2);
|
||||
expect(b).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("list rejects empty tenantId with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await store.list("");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudienceRulesError);
|
||||
expect((caught as AudienceRulesError).code).toBe(
|
||||
AUDIENCE_RULES_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("updateCache updates cached fields and lastRefreshedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const created = await store.create(VALID_INPUT);
|
||||
const result = await store.updateCache(created.id, ["c1", "c2"]);
|
||||
expect(result?.cachedContactIds).toEqual(["c1", "c2"]);
|
||||
expect(result?.cachedCount).toBe(2);
|
||||
expect(result?.lastRefreshedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("updateCache returns undefined for unknown id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await store.updateCache("missing", ["c1"]);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses injected Clock for createdAt and updatedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const FIXED = 1_700_000_000_000;
|
||||
const s = new InMemorySegmentStore({ clock: () => FIXED });
|
||||
const c = await s.create(VALID_INPUT);
|
||||
expect(c.createdAt.getTime()).toBe(FIXED);
|
||||
expect(c.updatedAt.getTime()).toBe(FIXED);
|
||||
const u = await s.update(c.id, { name: "x" });
|
||||
expect(u?.updatedAt.getTime()).toBe(FIXED);
|
||||
const cached = await s.updateCache(c.id, ["c1"]);
|
||||
expect(cached?.lastRefreshedAt?.getTime()).toBe(FIXED);
|
||||
expect(cached?.updatedAt.getTime()).toBe(FIXED);
|
||||
});
|
||||
|
||||
it("uses injected IdGenerator for segment IDs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = new InMemorySegmentStore({
|
||||
idGenerator: () => "seg_fixed",
|
||||
});
|
||||
const c = await s.create(VALID_INPUT);
|
||||
expect(c.id).toBe("seg_fixed");
|
||||
});
|
||||
|
||||
it("rejects create with empty name and raises INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await store.create({ ...VALID_INPUT, name: "" });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudienceRulesError);
|
||||
expect((caught as AudienceRulesError).code).toBe(
|
||||
AUDIENCE_RULES_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects update with empty name and raises INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await store.create(VALID_INPUT);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await store.update(c.id, { name: "" });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudienceRulesError);
|
||||
expect((caught as AudienceRulesError).code).toBe(
|
||||
AUDIENCE_RULES_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects update with empty patch and raises INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await store.create(VALID_INPUT);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await store.update(c.id, {});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AudienceRulesError);
|
||||
});
|
||||
});
|
||||
151
packages/commerce/contacts/audience-rules/src/store/store.ts
Normal file
151
packages/commerce/contacts/audience-rules/src/store/store.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import {
|
||||
AUDIENCE_RULES_CODES,
|
||||
AudienceRulesError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createSegmentInputSchema,
|
||||
parseOrThrow,
|
||||
updateSegmentInputSchema,
|
||||
} from "../schemas/schemas.js";
|
||||
import type {
|
||||
CreateSegmentInput,
|
||||
Segment,
|
||||
UpdateSegmentInput,
|
||||
} from "../types/segment.types.js";
|
||||
|
||||
/** Interface for segment persistence. */
|
||||
export interface SegmentStore {
|
||||
create(input: CreateSegmentInput): Promise<Segment>;
|
||||
getById(id: string): Promise<Segment | undefined>;
|
||||
update(id: string, input: UpdateSegmentInput): Promise<Segment | undefined>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
list(tenantId: string): Promise<readonly Segment[]>;
|
||||
updateCache(
|
||||
id: string,
|
||||
contactIds: readonly string[]
|
||||
): Promise<Segment | undefined>;
|
||||
}
|
||||
|
||||
/** Constructor options for {@link InMemorySegmentStore}. */
|
||||
export interface InMemorySegmentStoreOptions {
|
||||
/** Injected clock; defaults to `() => Date.now()`. */
|
||||
readonly clock?: Clock;
|
||||
/** Injected ID generator; defaults to a deterministic per-instance counter. */
|
||||
readonly idGenerator?: IdGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory `SegmentStore` implementation suitable for development, tests, and
|
||||
* lightweight production use. Uses `Clock` + `IdGenerator` injection from
|
||||
* `@suaveplan/types` — production paths never call `Date.now()` / `new Date()`
|
||||
* directly and never mutate a module-scope counter.
|
||||
*/
|
||||
export class InMemorySegmentStore implements SegmentStore {
|
||||
private readonly segments = new Map<string, Segment>();
|
||||
private readonly clock: Clock;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
|
||||
public constructor(options: InMemorySegmentStoreOptions = {}) {
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
this.idGenerator = options.idGenerator ?? this.makeDefaultIdGenerator();
|
||||
}
|
||||
|
||||
public async create(input: CreateSegmentInput): Promise<Segment> {
|
||||
const validated = parseOrThrow(
|
||||
createSegmentInputSchema,
|
||||
input,
|
||||
"createSegment"
|
||||
);
|
||||
const now = new Date(this.clock());
|
||||
const segment: Segment = {
|
||||
id: this.idGenerator(),
|
||||
tenantId: validated.tenantId,
|
||||
name: validated.name,
|
||||
description: validated.description,
|
||||
rules: validated.rules,
|
||||
refreshMode: validated.refreshMode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.segments.set(segment.id, segment);
|
||||
return segment;
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<Segment | undefined> {
|
||||
return this.segments.get(id);
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
input: UpdateSegmentInput
|
||||
): Promise<Segment | undefined> {
|
||||
const validated = parseOrThrow(
|
||||
updateSegmentInputSchema,
|
||||
input,
|
||||
"updateSegment"
|
||||
);
|
||||
const existing = this.segments.get(id);
|
||||
if (existing === undefined) return undefined;
|
||||
|
||||
const updated: Segment = {
|
||||
...existing,
|
||||
...(validated.name !== undefined && { name: validated.name }),
|
||||
...(validated.description !== undefined && {
|
||||
description: validated.description,
|
||||
}),
|
||||
...(validated.rules !== undefined && { rules: validated.rules }),
|
||||
...(validated.refreshMode !== undefined && {
|
||||
refreshMode: validated.refreshMode,
|
||||
}),
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.segments.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<boolean> {
|
||||
return this.segments.delete(id);
|
||||
}
|
||||
|
||||
public async list(tenantId: string): Promise<readonly Segment[]> {
|
||||
if (tenantId.length === 0) {
|
||||
throw new AudienceRulesError(
|
||||
AUDIENCE_RULES_CODES.INVALID_INPUT,
|
||||
"list requires a non-empty tenantId",
|
||||
{ tenantId }
|
||||
);
|
||||
}
|
||||
const results: Segment[] = [];
|
||||
for (const segment of this.segments.values()) {
|
||||
if (segment.tenantId === tenantId) results.push(segment);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async updateCache(
|
||||
id: string,
|
||||
contactIds: readonly string[]
|
||||
): Promise<Segment | undefined> {
|
||||
const existing = this.segments.get(id);
|
||||
if (existing === undefined) return undefined;
|
||||
const now = new Date(this.clock());
|
||||
const updated: Segment = {
|
||||
...existing,
|
||||
cachedContactIds: contactIds,
|
||||
cachedCount: contactIds.length,
|
||||
lastRefreshedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.segments.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private makeDefaultIdGenerator(): IdGenerator {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
counter += 1;
|
||||
return `seg_${counter}`;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# segment.types
|
||||
|
||||
## Purpose
|
||||
|
||||
`segment.types.ts` defines every shared type, interface, and closed union
|
||||
consumed by `@suaveplan/audience-rules`. Centralising the data shapes in one
|
||||
module gives the package a single auditable contract for the segmentation
|
||||
domain — `Segment`, `SegmentRule`, the `SegmentOperator` and `LogicalOp`
|
||||
discriminated unions, `RefreshMode`, the `SegmentEvaluationResult` carried
|
||||
back from the evaluator, the `ContactData` shape consumed by it, and the
|
||||
`CreateSegmentInput` / `UpdateSegmentInput` boundary types that the store's
|
||||
Zod schemas validate against.
|
||||
|
||||
## Features
|
||||
|
||||
- `SegmentOperator` — closed 14-member union of comparison operators.
|
||||
- `LogicalOp` — closed `"and" | "or"` union.
|
||||
- `RefreshMode` — closed `"realtime" | "scheduled" | "manual"` union.
|
||||
- `SegmentRule` interface — `field`, `operator`, optional `value` and
|
||||
`logicalOp`. Optional members are typed `T | undefined` to satisfy
|
||||
`exactOptionalPropertyTypes` against Zod-inferred outputs.
|
||||
- `Segment` interface — full persisted shape with optional cache fields
|
||||
(`cachedContactIds`, `cachedCount`, `lastRefreshedAt`).
|
||||
- `CreateSegmentInput` / `UpdateSegmentInput` — boundary types matching the
|
||||
Zod schemas in `schemas/schemas.ts`.
|
||||
- `SegmentEvaluationResult` — return shape from `SegmentEvaluator.evaluate`.
|
||||
- `ContactData` + `ContactDataChannel` + `ContactDataDelivery` — contact
|
||||
shape consumed by the evaluator's `ContactDataProvider`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Segment,
|
||||
SegmentRule,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
const rule: SegmentRule = {
|
||||
field: "status",
|
||||
operator: "eq",
|
||||
value: "active",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ContactData,
|
||||
LogicalOp,
|
||||
Segment,
|
||||
SegmentEvaluationResult,
|
||||
} from "@suaveplan/audience-rules";
|
||||
|
||||
function combine(
|
||||
a: SegmentEvaluationResult,
|
||||
b: SegmentEvaluationResult,
|
||||
op: LogicalOp
|
||||
): readonly string[] {
|
||||
if (op === "or") {
|
||||
return Array.from(new Set([...a.contactIds, ...b.contactIds]));
|
||||
}
|
||||
const bSet = new Set(b.contactIds);
|
||||
return a.contactIds.filter((id) => bSet.has(id));
|
||||
}
|
||||
|
||||
const contact: ContactData = {
|
||||
id: "c1",
|
||||
tags: ["vip"],
|
||||
metadata: { country: "US" },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SegmentOperator` — `"eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "not_contains" | "in" | "not_in" | "exists" | "not_exists" | "starts_with" | "ends_with"`.
|
||||
- `type LogicalOp` — `"and" | "or"`.
|
||||
- `type RefreshMode` — `"realtime" | "scheduled" | "manual"`.
|
||||
- `interface SegmentRule` — see Features.
|
||||
- `interface Segment` — see Features.
|
||||
- `interface CreateSegmentInput` — `tenantId`, `name`, `description?`,
|
||||
`rules`, `refreshMode`.
|
||||
- `interface UpdateSegmentInput` — every field optional; the
|
||||
corresponding Zod schema rejects an empty patch.
|
||||
- `interface SegmentEvaluationResult` — `contactIds`, `total`,
|
||||
`evaluatedAt`, `matchRate`.
|
||||
- `interface ContactData` — `id`, optional `tags`, `channels`,
|
||||
`status`, `metadata`, `lastActivityAt`, `deliveryHistory`.
|
||||
- `interface ContactDataChannel` — `type`, `value`.
|
||||
- `interface ContactDataDelivery` — `date`, `channel`, `status`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Every optional property is explicitly typed `T | undefined` rather than
|
||||
using just `?:` because the suaveplan tsconfig sets
|
||||
`exactOptionalPropertyTypes: true`. This lets the Zod-inferred outputs
|
||||
(which always include `undefined` in optional unions) assign cleanly to
|
||||
these interfaces without intermediate casts.
|
||||
- `ContactData.metadata` is `Readonly<Record<string, unknown>>` so the
|
||||
evaluator's `resolveField` can walk nested metadata while keeping the
|
||||
surface immutable for callers.
|
||||
- Adding a new `SegmentOperator` is a breaking change — it widens the
|
||||
closed union and forces consumers' switch tables to update. The
|
||||
preferred extension path is a sibling predicate factory, not a new
|
||||
operator.
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Refresh mode for segment cache evaluation.
|
||||
* - `realtime`: re-evaluated on every query
|
||||
* - `scheduled`: re-evaluated on a timer
|
||||
* - `manual`: re-evaluated only on explicit request
|
||||
*/
|
||||
export type RefreshMode = "realtime" | "scheduled" | "manual";
|
||||
|
||||
/** Closed union of fourteen comparison operators supported by `SegmentEvaluator`. */
|
||||
export type SegmentOperator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "contains"
|
||||
| "not_contains"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "exists"
|
||||
| "not_exists"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
|
||||
/** Logical operator combining a rule with the running result of previous rules. */
|
||||
export type LogicalOp = "and" | "or";
|
||||
|
||||
/** A single filter rule. Field is a dot-notation path; value is unused for `exists`/`not_exists`. */
|
||||
export interface SegmentRule {
|
||||
/** Dot-notation field path, e.g. `metadata.country` or `channels.0.type`. */
|
||||
readonly field: string;
|
||||
/** Comparison operator. */
|
||||
readonly operator: SegmentOperator;
|
||||
/** Value to compare against (unused for `exists`/`not_exists`). */
|
||||
readonly value?: unknown | undefined;
|
||||
/** How this rule combines with the previous rule (default `"and"`). */
|
||||
readonly logicalOp?: LogicalOp | undefined;
|
||||
}
|
||||
|
||||
/** A persisted segment definition with rules, tenant scope, refresh mode, and optional cache. */
|
||||
export interface Segment {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly rules: readonly SegmentRule[];
|
||||
readonly refreshMode: RefreshMode;
|
||||
readonly cachedContactIds?: readonly string[] | undefined;
|
||||
readonly cachedCount?: number | undefined;
|
||||
readonly lastRefreshedAt?: Date | undefined;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Input accepted by `SegmentStore.create`. */
|
||||
export interface CreateSegmentInput {
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly rules: readonly SegmentRule[];
|
||||
readonly refreshMode: RefreshMode;
|
||||
}
|
||||
|
||||
/** Input accepted by `SegmentStore.update` — every field optional. */
|
||||
export interface UpdateSegmentInput {
|
||||
readonly name?: string | undefined;
|
||||
readonly description?: string | undefined;
|
||||
readonly rules?: readonly SegmentRule[] | undefined;
|
||||
readonly refreshMode?: RefreshMode | undefined;
|
||||
}
|
||||
|
||||
/** Result of evaluating a segment against a tenant's contacts. */
|
||||
export interface SegmentEvaluationResult {
|
||||
readonly contactIds: readonly string[];
|
||||
readonly total: number;
|
||||
readonly evaluatedAt: Date;
|
||||
/** `matchingContacts / totalContacts`, or `0` when there are no contacts. */
|
||||
readonly matchRate: number;
|
||||
}
|
||||
|
||||
/** A single contact channel (e.g. email, sms, push) carried on `ContactData`. */
|
||||
export interface ContactDataChannel {
|
||||
readonly type: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
/** A single delivery-history entry carried on `ContactData`. */
|
||||
export interface ContactDataDelivery {
|
||||
readonly date: Date;
|
||||
readonly channel: string;
|
||||
readonly status: string;
|
||||
}
|
||||
|
||||
/** Contact shape consumed by `SegmentEvaluator`. */
|
||||
export interface ContactData {
|
||||
readonly id: string;
|
||||
readonly tags?: readonly string[] | undefined;
|
||||
readonly channels?: readonly ContactDataChannel[] | undefined;
|
||||
readonly status?: string | undefined;
|
||||
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
||||
readonly lastActivityAt?: Date | undefined;
|
||||
readonly deliveryHistory?: readonly ContactDataDelivery[] | undefined;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Polyfills for test environment — none required. Universal package.
|
||||
export { };
|
||||
12
packages/commerce/contacts/audience-rules/tsconfig.json
Normal file
12
packages/commerce/contacts/audience-rules/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/commerce/contacts/audience-rules/tsconfig.lib.json
Normal file
12
packages/commerce/contacts/audience-rules/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/commerce/contacts/audience-rules/vite.config.ts
Normal file
37
packages/commerce/contacts/audience-rules/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audience-rules",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/commerce/contacts/audience-rules/vitest.config.ts
Normal file
27
packages/commerce/contacts/audience-rules/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user