@blueforge-studio/service-crm
CRM service factory: createCrmService with memory and console adapters (contacts, tags, idempotent events); results never throw
Package Info
Versions
| Version | Published |
|---|---|
| 0.1.1 | 2026-10-16 |
| 0.1.0 | 2026-10-16 |
Per-version supply chain
| Version | Sig | SBOM | Att |
|---|---|---|---|
| 0.1.1 | |||
| 0.1.0 |
Click a version to view its details.
Supply chain
Provenance
- Published by
- web:5
- When
- 2026-10-16T18:13:35Z
- sha256 (registry-computed)
- 589fd511a34c0baee708f7b303ad371aca6675ce807663847fa16cb3caeedf80
Observed by the registry at publish.
Claimed by the build
The publisher claimed nothing about its build.
Attachments
- Signature
- none
- SBOM
- none
Dependencies
README
@blueforge-studio/service-crm
Provider-neutral CRM access for apps: createCrmService() reads CRM_PROVIDER and returns a stable CrmService
(upsertContact, addTags, recordEvent). Same pattern as service-support / service-analytics.
CRM_PROVIDER | What it is |
|---|---|
memory (default) | In-memory, silent. Dev and tests; also the reference for the semantics a real adapter must match. |
console | Memory plus one log line per call. Prints e-mail addresses: development only. |
There is no marketing-os adapter yet, on purpose. marketing-os has a CRM, but (2026-10-16, from that session) its only write path
for another app is an HMAC ingest for a closed list of four source apps (each needs a code change and an operator-registered secret);
the API-key route for other apps is a proposal (marketing-os PR #481, not merged), and its sdk-crm types have drifted from the server.
When that route exists, an adapter slots in behind the same interface.
import { createCrmService } from "@blueforge-studio/service-crm";
const crm = createCrmService();
// 1. Keep YOUR record of the lead first (your inbox / database). It is the source of truth.
await saveToInbox(form);
// 2. Then tell the CRM, best-effort. This never throws.
const contact = await crm.upsertContact({ email: form.email, name: form.name, locale: "en", tags: ["waitlist"], source: "footer-waitlist" });
const event = await crm.recordEvent({ email: form.email, type: "waitlist", source: "footer-waitlist", intent: "waitlist" });
if (!contact.ok || !event.ok) log.warn({ contact, event }); // the lead is already safe
Naming: source, type, intent
source: where the person came from: the form or surface (footer-waitlist,hero,contact-form).type: what happened:signup | waitlist | contact(other strings allowed).intent: optional refinement of the type (newslettervswaitlist).
Don't build source out of the type or intent (that is how you get waitlist-waitlist): they are already separate fields, and the
default idempotency key is email|type|source|intent.
Inspecting state in tests
createCrmService() returns the CrmService interface. To assert on what was stored, construct the adapter directly:
import { MemoryCrmAdapter } from "@blueforge-studio/service-crm";
const crm = new MemoryCrmAdapter(); // inject it where your code takes a CrmService
await handler(crm);
expect(crm.contacts()).toHaveLength(1); // normalised contacts
expect(crm.events()[0]).toMatchObject({ type: "waitlist", source: "footer-waitlist" });
Semantics
- Results, not exceptions. Every call resolves to
{ ok: true, value }or{ ok: false, error, retryable }. Bad input (invalid-email,missing-source,missing-type) andcontact-not-foundare not retryable; a backend adapter marks transient failures retryable. A failing CRM must never fail the user's request. - Identity is the normalised e-mail (trimmed, lower-cased).
upsertContactmerges: tags are unioned, empty fields never overwrite existing ones,sourceandcreatedAtstay from the first write.consent({ marketing, recordedAt }) is stored as given; the package does not decide what consent your use requires.recordEventis idempotent onemail|type|source|intentby default (same person, same thing, same place = one event; the second call returnsduplicate: true). PassidempotencyKey(e.g. a submission id) when repeats are legitimate.- Event types
signup | waitlist | contactare the known kinds; other strings are allowed.
Not here: reading contacts back, deleting/erasing a contact (GDPR requests need a real backend), segmentation, scoring.