Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# Migrate @suaveplan/audience-rules
**Package**: `@suaveplan/audience-rules`
**Status**: PROPOSED
**Type**: new-package (migration from `../suaveplan/packages/universal/audience-rules/`)
**Design**: (none — see `specs/audience-rules/spec.md` for the normative contract)
**Spec delta**: ./specs/audience-rules/spec.md
## Why
Audience segmentation is a universal-tier primitive consumed by every recipient-selection surface (campaigns, broadcasts, deliveries, notifications). Upstream `@suaveplan/audience-rules` exists at `packages/universal/audience-rules/` on main: it ships a working rule evaluator with 14 operators, AND/OR chaining, dot-notation field resolution, three refresh modes, predicate helpers, an `InMemorySegmentStore`, and an `EventEmitter` for refresh notifications. It is functionally complete but enterprise-incomplete — `EventEmitter` is the legacy `@suaveplan/events` v1 API (genesis uses typed `EventBus<TMap>`), there is no structured error class, no `defineCodes` registry, no Zod boundary, and no injectable `Clock` / `IdGenerator` (the in-memory store uses `new Date()` directly and a numeric counter at module scope).
This migration ports the full public surface to genesis under the §0a parity rules and lifts it to enterprise quality: typed `EventBus<SegmentRefresherEventMap>` from genesis `@suaveplan/events`, structured `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` with stable `AUDIENCE_RULES.*` code registry, Zod boundary validation on every public input, injectable `Clock` + `IdGenerator` on the in-memory store, sibling `.md` per implementation file, explicit-named feature barrels, 100% Istanbul coverage.
## What changes
- ADDED: 17 public exports preserved from upstream's `src/index.ts` — `SegmentEvaluator`, `ContactDataProvider`, `SegmentRefresher`, `SegmentStore`, `InMemorySegmentStore`, the seven predicate factories (`hasTag`, `channelIs`, `statusIs`, `lastActivityBefore`, `lastActivityAfter`, `metadataEquals`, `metadataContains`), and the entity / input / result types (`ContactData`, `Segment`, `SegmentRule`, `SegmentOperator`, `LogicalOp`, `RefreshMode`, `SegmentEvaluationResult`, `CreateSegmentInput`, `UpdateSegmentInput`).
- ADDED: `AUDIENCE_RULES_CODES` registry via `defineCodes("AUDIENCE_RULES", [...])` with stable codes (`INVALID_INPUT`, `SEGMENT_NOT_FOUND`, `UNKNOWN_OPERATOR`, `PROVIDER_FAILED`).
- ADDED: `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` with optional `segmentId` / `tenantId` discriminants.
- ADDED: Zod schemas for `SegmentRule`, `CreateSegmentInput`, `UpdateSegmentInput` enforced at the public store boundary.
- ADDED: Injectable `Clock` + `IdGenerator` on `InMemorySegmentStore` (defaults: `() => Date.now()` + a deterministic per-instance counter). `new Date()` and counter mutation removed from production paths.
- MODIFIED: `SegmentRefresher` consumes the genesis typed `EventBus<SegmentRefresherEventMap>` instead of the upstream untyped `EventEmitter`. The consumer API (`on("segment:refreshed", handler)`) is preserved verbatim.
- MODIFIED: `SegmentEvaluator.applyOperator` raises `AudienceRulesError` with `UNKNOWN_OPERATOR` for an unrecognised operator (upstream silently returned `false`). The discriminated `SegmentOperator` union makes the unknown branch reachable only via a `Rule` cast — the test exercises this case explicitly.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `SegmentEvaluator` + `ContactDataProvider` | ✅ | ✅ | dot-notation field resolution preserved verbatim |
| 14 comparison operators (eq/neq/gt/gte/lt/lte/contains/not_contains/in/not_in/exists/not_exists/starts_with/ends_with) | ✅ | ✅ | switch retained |
| AND/OR logical chaining | ✅ | ✅ | left-to-right; default `and` |
| Three refresh modes (realtime/scheduled/manual) | ✅ | ✅ | type-only enum preserved |
| Seven predicate factories | ✅ | ✅ | verbatim |
| `SegmentStore` interface + `InMemorySegmentStore` | ✅ | ⬆️ | Enhanced: `Clock` + `IdGenerator` injection, Zod boundary |
| `SegmentRefresher` + event emission | ✅ | ⬆️ | Enhanced: typed `EventBus<SegmentRefresherEventMap>` replaces untyped `EventEmitter` |
| Multi-tenant isolation via `tenantId` | ✅ | ✅ | preserved on `Segment` and store list |
| Structured error class + code registry | 🚫 | ⬆️ | Added: `AudienceRulesError` + `AUDIENCE_RULES_CODES` |
| Zod-validated public boundaries | 🚫 | ⬆️ | Added: Zod schemas on store inputs |
| OTel instrumentation | 🚫 | 🚫 | Scoped out: upstream has none and audience-rules is a lightweight evaluator; instrumentation belongs in `api-plugin-audience-rules` (HTTP layer) where request-level cardinality is meaningful. |
## Out of scope
- SurrealDB / Postgres store adapters — upstream defers these to a hypothetical `api-plugin-audience-rules`; genesis matches.
- Scheduled-refresh timer wiring — upstream exposes the `scheduled` refresh mode as a type only; the timer driver is a consumer responsibility. Genesis matches.
- Real-time push refresh (e.g. CDC-driven re-evaluation) — same reason; consumer responsibility.
## Source mapping
| Requirement | Source lines | Notes |
|---|---|---|
| Public surface (17 exports) | `src/index.ts:1-26` | verbatim |
| `Segment`, `SegmentRule`, `SegmentOperator`, `LogicalOp`, `RefreshMode`, `SegmentEvaluationResult`, `ContactData`, `CreateSegmentInput`, `UpdateSegmentInput` types | `src/types/segment.types.ts:1-92` | verbatim shape |
| `SegmentEvaluator.evaluate` + `evaluateRule` + 14-operator switch + dot-notation `resolveField` + AND/OR `matchesRules` | `src/core/segment-evaluator/segment-evaluator.ts:1-141` | verbatim algorithm |
| `SegmentRefresher.refresh` + `refreshAll` + event emission | `src/core/segment-refresher/segment-refresher.ts:1-66` | `EventEmitter` → typed `EventBus` |
| `SegmentStore` + `InMemorySegmentStore` (6 methods) | `src/stores/segment.store.ts:1-90` | Enhanced with `Clock`+`IdGenerator` + Zod |
| Seven predicate factories | `src/predicates/predicates.ts:1-44` | verbatim |
| Universal-tier purity | upstream `package.json` (no engines, no `browser` field) | preserved |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `AudienceRulesError` + `AUDIENCE_RULES_CODES` registry | Genesis-wide policy: every package raises a typed error. |
| Zod boundary at the store | Genesis-wide policy: every public input is Zod-validated. |
| Injectable `Clock` + `IdGenerator` | Genesis-wide policy: no `Date.now()` / `new Date()` / counter mutation in production paths. |
| Typed `EventBus` swap | Genesis `@suaveplan/events` ships typed `EventBus<TMap>`; migration must use it. |
## Verification approach
- `src/error-codes/error-codes.test.ts` — registry shape, every code constructible, discriminants exposed
- `src/segment-evaluator/segment-evaluator.test.ts` — every operator, AND/OR chaining, dot-notation resolution, empty-rules / empty-contacts edge cases, unknown-operator raises `UNKNOWN_OPERATOR`
- `src/segment-refresher/segment-refresher.test.ts` — `refresh`, `refreshAll`, typed event emission, cache update, undefined for unknown segment
- `src/store/store.test.ts` — every interface method, multi-tenant isolation, Zod validation, `Clock` + `IdGenerator` injection
- `src/predicates/predicates.test.ts` — every factory produces correct rule shape and evaluator matches behave correctly
- `src/audience-rules.spec-conformance.test.ts` — tier purity, barrel shape, subpath exports, error-code registry, runner-import gate
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/audience-rules` returns no dependents on genesis (no consumer migrated yet).
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/audience-rules`).

View File

@@ -0,0 +1,183 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: audience-rules (enterprise-audience-rules)
**Package**: `@suaveplan/audience-rules`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Universal-tier purity
The package SHALL run unchanged in Bun, Node, and browser environments. The `src/` tree SHALL NOT import from `node:*`, access `window` / `document` / `navigator`, or depend on any browser-only or Node-only package.
#### Scenario: tier-purity grep is empty
- **WHEN** the spec-conformance test walks `src/` for `from "node:`, `globalThis.document`, `navigator.userAgent`
- **THEN** zero offenders are reported
**Verified by:** `src/audience-rules.spec-conformance.test.ts::"src tree contains no browser or Node-only globals"`
---
### Requirement: Stable error-code registry
The package SHALL expose `AUDIENCE_RULES_CODES` built via `defineCodes("AUDIENCE_RULES", [...])` and an `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` with optional `segmentId` / `tenantId` discriminants. Every failure path in the package SHALL raise an `AudienceRulesError`, never a bare `Error`.
#### Scenario: every code is constructible
- **WHEN** the test instantiates `new AudienceRulesError(code, "msg", { segmentId, tenantId })` for every member of `AUDIENCE_RULES_CODES`
- **THEN** the resulting error narrows `code` to the namespaced string and exposes the discriminants
**Verified by:** `src/error-codes/error-codes.test.ts::"every registered code constructs an AudienceRulesError with namespaced code"` AND `src/error-codes/error-codes.test.ts::"every code exposes its discriminants on the instance"`
---
### Requirement: Fourteen comparison operators
`SegmentEvaluator.evaluateRule` SHALL support fourteen operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`, `ends_with`. The `contains` / `not_contains` operators SHALL match both array membership and string substring. The `in` / `not_in` operators SHALL check membership in an array of values supplied as the rule value. An operator outside this set SHALL cause `evaluateRule` to throw `AudienceRulesError` with code `UNKNOWN_OPERATOR`.
#### Scenario: each operator returns the documented boolean
- **WHEN** the test runs every operator against a known contact / rule pair
- **THEN** the result matches the expected boolean
#### Scenario: unknown operator raises UNKNOWN_OPERATOR
- **WHEN** `evaluateRule` is called with `operator: "unknown" as never`
- **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.UNKNOWN_OPERATOR` is thrown
**Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"applies every operator with the documented boolean result"` AND `src/segment-evaluator/segment-evaluator.test.ts::"raises UNKNOWN_OPERATOR for an unrecognised operator"`
---
### Requirement: AND/OR logical chaining
When a `Segment` carries multiple rules, the evaluator SHALL combine them left-to-right respecting each rule's `logicalOp` (default `"and"`). The first rule's `logicalOp` is ignored; each subsequent rule combines with the running result. An empty rule list SHALL match every contact.
#### Scenario: AND narrows the match set
- **WHEN** a segment carries two rules combined with `logicalOp: "and"`
- **THEN** only contacts matching both rules appear in the result
#### Scenario: OR widens the match set
- **WHEN** a segment carries two rules where the second has `logicalOp: "or"`
- **THEN** contacts matching either rule appear in the result
#### Scenario: empty rules match all
- **WHEN** a segment carries zero rules
- **THEN** the evaluator returns every contact
**Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"narrows the match set when rules are AND-combined"` AND `src/segment-evaluator/segment-evaluator.test.ts::"widens the match set when rules are OR-combined"` AND `src/segment-evaluator/segment-evaluator.test.ts::"matches every contact when the rule list is empty"`
---
### Requirement: Dot-notation field resolution
The evaluator SHALL resolve dot-separated paths against the contact, supporting numeric indices for array elements (e.g. `channels.0.type`). Resolution against a missing intermediate path SHALL return `undefined` (the rule does not match unless the operator is `not_exists`).
#### Scenario: nested object path resolves
- **WHEN** the rule field is `metadata.country` and the contact has `{ metadata: { country: "US" } }`
- **THEN** the evaluator resolves to `"US"`
#### Scenario: array index resolves
- **WHEN** the rule field is `channels.0.type` and the contact has a single channel of type `"email"`
- **THEN** the evaluator resolves to `"email"`
#### Scenario: missing nested path returns undefined
- **WHEN** the rule field is `a.b.c.d` and the contact does not carry `a`
- **THEN** the evaluator treats the value as `undefined`
**Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"resolves dot-notation paths through nested objects"` AND `src/segment-evaluator/segment-evaluator.test.ts::"resolves numeric indices into array fields"` AND `src/segment-evaluator/segment-evaluator.test.ts::"returns undefined for missing nested paths"`
---
### Requirement: Predicate factories
The package SHALL export seven `SegmentRule` factories: `hasTag(tag)`, `channelIs(type)`, `statusIs(status)`, `lastActivityBefore(date)`, `lastActivityAfter(date)`, `metadataEquals(field, value)`, `metadataContains(field, value)`. Each factory SHALL return a `SegmentRule` whose `field` / `operator` / `value` produce a match consistent with the predicate's name.
#### Scenario: each predicate produces the documented rule shape
- **WHEN** each factory is called with a representative input
- **THEN** the returned `SegmentRule` carries the documented `field` and `operator`
**Verified by:** `src/predicates/predicates.test.ts::"hasTag produces a tags-contains rule"` AND `src/predicates/predicates.test.ts::"channelIs produces a channels.0.type-eq rule"` AND `src/predicates/predicates.test.ts::"statusIs produces a status-eq rule"` AND `src/predicates/predicates.test.ts::"lastActivityBefore produces a lastActivityAt-lt rule"` AND `src/predicates/predicates.test.ts::"lastActivityAfter produces a lastActivityAt-gt rule"` AND `src/predicates/predicates.test.ts::"metadataEquals produces a metadata-field-eq rule"` AND `src/predicates/predicates.test.ts::"metadataContains produces a metadata-field-contains rule"`
---
### Requirement: SegmentStore with Clock and IdGenerator injection
The package SHALL expose a `SegmentStore` interface with `create`, `getById`, `update`, `delete`, `list`, `updateCache` methods and an `InMemorySegmentStore` implementation. `InMemorySegmentStore` SHALL accept optional `clock: Clock` and `idGenerator: IdGenerator` injections from `@suaveplan/types` (defaults `() => Date.now()` and a deterministic per-instance counter). Production paths SHALL NOT call `Date.now()`, `new Date()` directly, or mutate a module-scope counter.
#### Scenario: injected Clock controls timestamps
- **WHEN** an `InMemorySegmentStore` is constructed with `clock: () => 1_700_000_000_000`
- **THEN** every `createdAt` / `updatedAt` / `lastRefreshedAt` reports `new Date(1_700_000_000_000)`
#### Scenario: injected IdGenerator controls segment IDs
- **WHEN** an `InMemorySegmentStore` is constructed with `idGenerator: () => "seg_fixed"`
- **THEN** `store.create({...}).id === "seg_fixed"`
**Verified by:** `src/store/store.test.ts::"uses injected Clock for createdAt and updatedAt"` AND `src/store/store.test.ts::"uses injected IdGenerator for segment IDs"`
---
### Requirement: Multi-tenant isolation
`SegmentStore.list(tenantId)` SHALL return only segments whose `tenantId` matches the argument. Cross-tenant segments SHALL be invisible to `list` even when stored in the same instance.
#### Scenario: list scopes to tenant
- **WHEN** an `InMemorySegmentStore` holds segments for `"tenant-a"` and `"tenant-b"`
- **THEN** `store.list("tenant-a")` returns only `tenant-a`'s segments
**Verified by:** `src/store/store.test.ts::"list returns only segments for the requested tenant"`
---
### Requirement: SegmentRefresher event emission
`SegmentRefresher` SHALL expose `refresh(segmentId)` and `refreshAll(tenantId)` methods. Each successful refresh SHALL update the store cache via `updateCache` and emit `segment:refreshed` on its `events: EventBus<SegmentRefresherEventMap>` field. `refresh` SHALL return `undefined` for an unknown segment ID without emitting. `refreshAll` SHALL emit once per refreshed segment.
#### Scenario: refresh updates cache and emits
- **WHEN** `refresher.refresh(segment.id)` is called for an existing segment
- **THEN** the segment's `cachedContactIds` / `cachedCount` / `lastRefreshedAt` are updated and a `segment:refreshed` listener observes `{ segmentId, result }`
#### Scenario: unknown segment returns undefined
- **WHEN** `refresher.refresh("missing")` is called
- **THEN** the result is `undefined` and no event is emitted
#### Scenario: refreshAll emits once per segment
- **WHEN** a tenant carries three segments and `refresher.refreshAll(tenant)` is called
- **THEN** the `segment:refreshed` listener is invoked three times
**Verified by:** `src/segment-refresher/segment-refresher.test.ts::"refresh updates the store cache and emits segment:refreshed"` AND `src/segment-refresher/segment-refresher.test.ts::"refresh returns undefined for an unknown segment ID"` AND `src/segment-refresher/segment-refresher.test.ts::"refreshAll emits segment:refreshed once per refreshed segment"`
---
### Requirement: Zod-validated input boundaries
Every public `SegmentStore.create` / `SegmentStore.update` call SHALL validate its input via a Zod schema before persisting. Validation failure SHALL raise `AudienceRulesError` with code `INVALID_INPUT` (never a raw `ZodError`).
#### Scenario: invalid create input is rejected
- **WHEN** `InMemorySegmentStore.create({ name: "", tenantId: "t1", rules: [], refreshMode: "manual" })` is called with an empty name
- **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` is thrown and no segment is persisted
#### Scenario: invalid update input is rejected
- **WHEN** `InMemorySegmentStore.update(id, { name: "" })` is called with an empty name
- **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` is thrown
**Verified by:** `src/store/store.test.ts::"rejects create with empty name and raises INVALID_INPUT"` AND `src/store/store.test.ts::"rejects update with empty name and raises INVALID_INPUT"`

View File

@@ -0,0 +1,64 @@
# Tasks — enterprise-audience-rules
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight
- [x] 0.1 Confirmed package is missing from genesis (`STATUS.md` has no audience-rules entry; greenfield migration from `../suaveplan/packages/universal/audience-rules/`).
- [x] 0.2 Read upstream `src/` end-to-end: `index.ts`, `types/segment.types.ts`, `core/segment-evaluator/`, `core/segment-refresher/`, `stores/segment.store.ts`, `predicates/predicates.ts`, plus all upstream test files.
- [x] 0.3 Read upstream `README.md` for the public-surface contract.
- [x] 0.4 Read reference package `@suaveplan/contacts` at `packages/commerce/contacts/contacts/` for genesis-conventions (5-files-per-feature, sibling `.md`, runner imports, `defineCodes`, `parseOrThrow`).
- [x] 0.5 Confirmed dependencies (`@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`, `zod`, `tslib`) all shipped on genesis.
- [x] 0.6 Numbered checklist of every `### Requirement:` in the spec delta (10 requirements, all listed in §1 below).
## 1. Spec authoring
- [x] 1.1 `proposal.md` with `## Parity Checklist`, `## Out of scope`, `## Source mapping`, `## Impact`.
- [x] 1.2 `specs/audience-rules/spec.md` — 10 `### Requirement:` blocks, each with `**Verified by:**`:
- [x] R1: Universal-tier purity
- [x] R2: Stable error-code registry (`AUDIENCE_RULES_CODES` + `AudienceRulesError`)
- [x] R3: 14-operator rule evaluation (eq/neq/gt/gte/lt/lte/contains/not_contains/in/not_in/exists/not_exists/starts_with/ends_with) plus `UNKNOWN_OPERATOR` for invalid operators
- [x] R4: AND/OR logical chaining
- [x] R5: Dot-notation field resolution with array index access
- [x] R6: Predicate factories (seven helpers)
- [x] R7: `SegmentStore` interface + `InMemorySegmentStore` with `Clock` + `IdGenerator` injection
- [x] R8: Multi-tenant isolation via `tenantId` on store list
- [x] R9: `SegmentRefresher` typed `EventBus<SegmentRefresherEventMap>` emission
- [x] R10: Zod-validated input boundaries
## 2. Implementation
- [x] 2.1 `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `tests/setup/polyfills.ts`
- [x] 2.2 `src/error-codes/error-codes.ts` — `AUDIENCE_RULES_CODES` + `AudienceRulesError` with `segmentId` / `tenantId` discriminants
- [x] 2.3 `src/types/segment.types.ts`
- [x] 2.4 `src/schemas/schemas.ts` — Zod schemas + `parseOrThrow`
- [x] 2.5 `src/store/store.ts` — `SegmentStore` + `InMemorySegmentStore` with `Clock`+`IdGenerator`
- [x] 2.6 `src/segment-evaluator/segment-evaluator.ts` — 14-operator evaluator + dot-notation + AND/OR + `UNKNOWN_OPERATOR`
- [x] 2.7 `src/segment-refresher/segment-refresher.ts` — typed `EventBus<SegmentRefresherEventMap>` integration
- [x] 2.8 `src/predicates/predicates.ts` — seven factories
- [x] 2.9 `src/index.ts` package barrel (explicit named re-exports)
- [x] 2.10 Feature `index.ts` barrels (explicit named re-exports — no `export *`)
## 3. Tests
- [x] 3.1 Unit tests for every implementation file via `@suaveplan/testing/runner`
- [x] 3.2 `src/audience-rules.spec-conformance.test.ts` — tier purity, barrel shape, subpath count, runner-import gate, error-code registry shape
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` per implementation `.ts` (≥200 words, Purpose / Features / Basic / Advanced / API Reference / Implementation Notes)
- [x] 4.2 `README.md` at package root with Quick start + `## Modules` linking every sibling `.md`
## 5. Pre-publish gate
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/audience-rules` — green
- [x] 5.2 `bunx turbo test:coverage --filter=@suaveplan/audience-rules` — 100/100/100/100
- [x] 5.3 `bunx turbo build --filter=@suaveplan/audience-rules` — green
- [x] 5.4 `bun ./scripts/docs-check.ts packages/commerce/contacts/audience-rules` — 100% pass
## 6. Archive
- [x] 6.1 Fold `specs/audience-rules/spec.md` into `openspec/specs/audience-rules/spec.md`
- [x] 6.2 Move change folder to `openspec/archive/2026-05-23-enterprise-audience-rules/`
- [x] 6.3 Run `bun scripts/folder-reorg/generate-manifest.ts`