Drop this customer's cached reads.
Lives here rather than on the writer because it's the cache's own
concern — it names the folded-get keys and the customer tag — so both
the reader (CustomerQueryService) and the writer (CustomerService)
inherit it. The actual caller is customerOnboardingActivities
(@cfxlabsinc/onboarding-services), which calls this once a primary
organization exists for the customer: a customer is invisible to
get/search until its primary org exists, so every read taken earlier
in onboarding cached a negative entry with a 24h L2 TTL, and this is what
clears it.
Bust every cached page — consumer or admin — that a write touching
customerIds could have affected.
The collection tag is always cleared alongside, because an unscoped admin page carries only that tag and no customer id can reach it.
keys deletes exact entries in addition to the tag surgery, passing the
key arguments — this cache hashes and prefixes them. It exists because
invalidateTag resolves keys through the FT index, which does not exist in
memory mode (valkeyClient: null); there the tag bust is a total no-op, and
an exact-key delete is the only thing that keeps read-after-write honest.
Services that folded get onto search pass that get's arguments.
These deletes only reach this instance's own key space, because the key space is partitioned per surface. That is sufficient, and a writer must not try to name the other surface's key:
get entry is itself tagged (by customer
id, or by the collection tag when unscoped), so the tag bust above already
evicts it from the shared L2 and publishes the peer eviction.OptionalcreatedAt?: DbTimestampCriteriaFilter to customers created within this window.
Optionalids?: string[]OptionalincludeWithoutMainOrg?: IncludeWithoutMainOrgTurn the primary-organization INNER JOIN into a LEFT JOIN, so customers
that have no primary organization yet are returned too — with
organizationId / legalEntityName absent (see
MaybeOnboardedCustomer).
A deliberate, narrow escape hatch from the rule that a Customer
is a fully-onboarded one. The only caller is
OrganizationService.create({ isMainOrg: true }), which has to read the
customer whose primary org it is in the middle of inserting; without this
it can never see it and retries forever. Nothing user-facing should pass
it.
Results are NOT cached when this is set: a half-built customer changes shape within seconds, and the 24h L2 TTL would outlive that by a long way.
Optionalname?: stringOptionalnameLike?: stringOptionalorderBy?: DbOrderByCriterion<Optionalpage?: numberDefaults to 1
OptionalpageSize?: numberDefaults to 50
OptionalpropelauthWorkspaceIds?: string[]Optionalq?: stringUnified free-text search. Case-insensitive substring match against the
customer name, the customer external id (cust_…), or the legal entity
(organization) name. Whitespace-only values are a no-op.
OptionalrouteFusionProgramIds?: string[]Filter to customers enrolled in these RouteFusion program ids
(matched against customer.data.routeFusionProgramId). Backs the admin
RouteFusion program-detail "Enrolled customers" list.
Optionalstatuses?: ("ACTIVE" | "FAILED" | "PENDING")[]Filter to customers in these provisioning-lifecycle statuses.
OptionalupdatedAt?: DbTimestampCriteriaFilter to customers updated within this window.
ProtectedsearchTags to write on a page, derived from the scope the query searched — never from the customers present in the result.
That distinction is load-bearing. An entry tagged with the customer ids in
the page carries no tags when the page is empty, so nothing can ever bust
it and a later create leaves it stale for the full L2 TTL. Tagging by
scope means an empty page still carries the tag for what it searched.
An unscoped read falls back to the collection tag, since no customer-id tag can reach a row whose owner did not exist when the entry was written.
StaticdeserializeThe inverse of serializeCustomer: rehydrates the Date fields.
StaticgetAs getCacheKey, for the get({ propelauthWorkspaceId }) fold.
StaticgetThe exact cache key CustomerQueryService.get({ id }) reads through.
get folds onto search, so its entry is an ordinary single-row search
page — these args must stay identical to the ones get passes
({ ids: [id], pageSize: 1 }, plus search's page default of 1). Every
other filter is undefined there, and serviceCacheKey canonicalizes with
JCS, which drops undefined properties, so naming only the three that are
set produces the same digest. customerCache.test.ts pins the equivalence.
Returns the key arguments; the cache hashes and surface-prefixes them. Never pre-hash at a call site.
StaticserializeCustomer → its JSON-safe form. Safe to call without a Valkey client.
Read-only access to customers.
Customer service is special: While it can interact with multiple customers like an admin service, when used in a non-admin context, it should only be used to interact with the current customer.
get()andsearch()INNER JOIN the customer's primary organization (organization.customer_id = customer.id AND organization.is_main_org), so every Customer they return carries a non-nullorganizationIdandlegalEntityName.That join also DEFINES what a
Customeris here: a fully-onboarded one. Customer and primary org are no longer created together —CustomerService.create()writes the customer row and nothing else, and@cfxlabsinc/onboarding-servicesthen callsOrganizationService.create({ isMainOrg: true }). Between those two steps, and after a failed onboarding run, a customer row exists with no primary org and is deliberately invisible toget/search. The onboarding workflow — the only thing that must see a customer in that half-built state — reads it throughsearch({ includeWithoutMainOrg: true }), which swaps that join for a LEFT one.Extends CustomerCache, which owns the
customer-searchnamespace, the seam that busts it, and the serialization that defines its stored shape.This class deliberately depends on nothing but
db.