Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user