Initial Commit
This commit is contained in:
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}`;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user