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,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`.

View File

@@ -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);
});
});

View 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}`;
};
}
}