# repositories/membership-repository — SurrealDB-backed MembershipStore ## Purpose Concrete implementation of the `MembershipStore` interface from `@suaveplan/contacts`, backed by `SurrealAdapter`. Persists the many-to-many `(groupId, contactId)` join with a `UNIQUE` SurrealQL index that raises `MEMBERSHIP_EXISTS` on duplicate insertion. The repository owns no business logic — idempotent add semantics are implemented by the route layer (`POST /groups/:id/members` checks `exists` before adding so the second call returns `{ added: 0 }` without surfacing a `MEMBERSHIP_EXISTS` error). ## Features - Full `MembershipStore` surface: `generateId`, `add`, `remove`, `getByGroup`, `getByContact`, `exists`, `removeAllForContact`, `removeAllForGroup`, `getContactIdsByGroups`, `countByGroup`. - Sniffs SurrealDB unique-index violations and translates them into `ContactsApiError(MEMBERSHIP_EXISTS)`; other adapter errors become `CREATE_FAILED` / `DELETE_FAILED`. - `getContactIdsByGroups([])` short-circuits to avoid a useless database round-trip. ## Usage: Basic ```ts import { SurrealMembershipRepository } from "@suaveplan/api-plugin-contacts"; const repo = new SurrealMembershipRepository(adapter); await repo.add({ id: repo.generateId(), groupId: "contact_group:engineering", contactId: "contact:ada", addedAt: new Date(), }); ``` ## Usage: Advanced ```ts import { SurrealMembershipRepository } from "@suaveplan/api-plugin-contacts"; const repo = new SurrealMembershipRepository(adapter, { idGenerator: () => crypto.randomUUID(), }); const contactIds = await repo.getContactIdsByGroups([ "contact_group:engineering", "contact_group:sales", ]); const unique = Array.from(new Set(contactIds)); ``` ## API Reference - `interface SurrealMembershipRepositoryOptions` — `{ idGenerator? }`. - `class SurrealMembershipRepository implements MembershipStore` — constructor `(db: SurrealAdapter, options?)`. ## Implementation Notes - `add` inspects the thrown error's message for the substrings `unique`, `UNIQUE`, or `duplicate` to classify a unique-index violation. SurrealDB's exact message format has drifted between versions; the substring approach survives the most common variants. - `exists` uses `count() AS count … GROUP ALL` rather than `SELECT … LIMIT 1` because `count` is consistently zero-or-one in SurrealDB and avoids deserialising the row. - `mapMembership` preserves the original `metadata` field shape — `undefined` stays `undefined` rather than being coerced to `{}`, so callers can distinguish "no metadata" from "empty metadata".