Repositories
Repositories are the only sanctioned way to reach the database. They live in
libs/morpheus-data-layer/morpheus_data_layer/repositories/, one file per model, and every
public name is re-exported from repositories/init.py.
Two rules govern all of them:
- Repositories are stateless
-
Instantiate once at module level —
_repo = SellerRepository()— and reuse across invocations. There is no per-request construction cost and no instance state to leak between Lambda invocations. - Repositories never own a transaction
-
Every method takes an explicit
Sessionas its first positional argument. No method callscommit(),rollback(), or opens a session. The caller does that withwith get_session() as session:, which commits on a clean exit and rolls back on an exception. A handler that calls three repositories inside onewithblock gets one atomic transaction across all three.
from morpheus_data_layer.repositories import SellerRepository, ContactRepository
from morpheus_data_layer.session import get_session
_seller_repo = SellerRepository()
_contact_repo = ContactRepository()
with get_session() as session:
seller = _seller_repo.upsert_by_seller_id(session, data)
_contact_repo.upsert_for_seller(session, contact_data)
# commit happens here, once, for both writes
BaseRepository
repositories/base.py — a generic BaseRepository[ModelT]. A subclass binds it to a model
with a single class attribute:
class SellerRepository(BaseRepository[Seller]):
model_class = Seller
| Method | Behaviour |
|---|---|
|
Primary-key lookup. Returns |
|
|
|
Paginated full scan. |
|
Equality filters on mapped attributes. Returns a list. |
|
Same filters, first match or |
|
|
|
Multi-row insert with no ORM instances returned. |
|
PostgreSQL |
|
|
|
Fetch then update. Returns |
|
|
The upsert contract
upsert() is how every idempotent write is done, because SQS delivers at-least-once and
schedules overlap.
repo.upsert(
session,
data,
conflict_columns=["sellerId"], (1)
update_columns=["businessName", "enrichedAt"], (2)
)
| 1 | Must match a real unique constraint or unique index, written as the camelCase database column name. |
| 2 | Optional. When omitted, every key in data except the conflict columns is updated. |
Because ORM attribute names are identical to column names (see ORM models), these lists are literal SQL identifiers — grep finds every place a column participates in a conflict resolution.
Repository reference
SellerRepository
repositories/seller.py → Seller
| Method | Behaviour |
|---|---|
|
Lookup by the external marketplace id, not the primary key. |
|
Filter on |
|
Tenant-scoped listing, newest first. |
|
|
|
|
|
|
|
Upsert on |
The two get_pending_* methods are the queue for the backfill schedules — they define what
"pending" means for enrichment and
scoring, and the ordering is what makes the backfill
fair rather than starving the oldest rows.
ContactRepository
repositories/contact.py → Contact
| Method | Behaviour |
|---|---|
|
Exact match on the nullable-unique |
|
Exact match on the nullable-unique |
|
Match on the CRM identifier. |
|
All contacts of one seller, newest first. |
|
Contacts with no |
|
Same population, ordered |
|
Identity resolution. Tries |
|
Find-or-create with merge semantics — see below. |
|
That behaviour exists because |
The two list_missing_* methods differ only in ordering, and both exist on purpose:
Hubspot.ContactResolve uses the priority-ordered one so that high-value contacts reach the
CRM first, while the age-ordered one guarantees nothing is stranded forever. See
CRM sync.
ActivityRepository
repositories/activity.py → Activity
| Method | Behaviour |
|---|---|
|
Lookup on the unique |
|
Timeline for one contact, newest first, optionally filtered by |
Activity upserts are performed by the caller rather than the repository. The BigQuery
engagement sync calls the base upsert() directly with:
conflict_columns=["subscriptionId", "externalId"]
update_columns=["type", "hubspotEngagementId", "subject", "body", "status", "direction",
"contactId", "contactEmail", "hubspotContactId", "metadata", "updatedAt"]
externalId is the HubSpot engagement id, which is what makes re-running the sync over an
overlapping watermark window free of duplicates. See
Scheduled events.
CallRepository
repositories/call.py → Call
| Method | Behaviour |
|---|---|
|
Lookup by the dialer’s own call identifier. |
|
Tenant-scoped fetch with |
|
Same, but also asserts the target id — defence in depth against a mismatched pair in a webhook. |
The Five9 sync upserts through the base method on conflict_columns=["providerCallId"],
updating targetPhoneNumber, direction, status, disposition, durationMs,
durationSec, startTimestamp, hubspotEngagementId, hubspotSyncStatus, contactName,
notes, targetPhoneNumbersId, and salesRepId. See
Call logging and dispositions.
SalesRepRepository
repositories/sales_rep.py → SalesRep
| Method | Behaviour |
|---|---|
|
Active rep matching an email address. |
|
Active rep matching a HubSpot owner id. |
|
Same lookup without the |
|
Active reps, ordered |
|
|
The id ASC ordering in list_active_for_subscription() is load-bearing: it is the stable
ordering that makes the round-robin cursor below meaningful.
Round-robin helpers
repositories/sales_rep_round_robin.py. Functions, not a class, because the state they
manage lives in the configs table rather than in a model.
| Name | Behaviour |
|---|---|
|
The constant |
|
Builds |
|
Reserves |
|
|
The batch function takes a PostgreSQL transaction-level advisory lock before reading the
cursor. Without it, two concurrent re-engagement handlers would read the same cursor value and
assign the same rep to both batches, defeating the point of round-robin. The lock is released
when the transaction ends, so it must be held inside the caller’s with get_session() block.
Reserving a batch and advancing once — rather than calling the single-rep helper in a loop — keeps the lock window short and the distribution even. Full rules in Ownership and round-robin.
ConfigsRepository
repositories/configs.py → Configs
| Method | Behaviour |
|---|---|
|
Row lookup on the globally unique |
|
The |
|
All rows for one tenant. |
|
Upsert with |
Every key in use is catalogued in Runtime state in configs.
FollowUpTaskRepository
repositories/follow_up_task.py → FollowUpTask
| Method | Behaviour |
|---|---|
|
|
|
|
|
True when a |
|
|
has_active_task_for_contact() is the deduplication guard every re-engagement handler calls
before creating work — it is what stops four segments from putting the same contact on a rep’s
list four times. The escalationStage < 3 ceiling caps escalation at three notifications.
See Follow-up tasks and escalation.
The eager loads matter: expire_on_commit=False keeps scalars readable after commit but
raises on an unloaded relationship, so a handler that formats a Slack message from
task.salesRep.email after the session closes needs the relationship already loaded.
Email repositories
| Repository | Model | Methods beyond BaseRepository |
|---|---|---|
|
|
None — a plain model wrapper. |
|
|
None — a plain model wrapper. |
|
|
|
|
|
|
update_categorisation() records category_model alongside the verdict, so a
misclassification can be traced back to the model that produced it — see
email-agent.
set_newsletter_opt_out() compares occurred_at against the stored newsletterOptOutAt and
ignores the write when the incoming event is older. HubSpot webhooks arrive out of order, and
without the guard a stale "subscribed" event could silently undo a later opt-out — a
compliance problem, not just a data problem.
TargetPhoneNumberRepository
repositories/target_phone_number.py → TargetPhoneNumber
| Method | Behaviour |
|---|---|
|
Substring match ( |
|
|
list_pending_twilio_sync() is the work queue for
phone line intelligence; the batch size comes from
PHONE_AGENT_BATCH_SIZE, and each Twilio Lookup call costs money, so the priority ordering
decides what the budget is spent on.
Exports
repositories/init.py exports exactly:
ActivityRepository, BaseRepository, CallRepository, ConfigsRepository,
ContactRepository, EmailRepository, EmailCampaignTargetRepository,
EmailVariationRepository, FollowUpTaskRepository, SalesRepRepository,
SellerRepository, TargetEmailsRepository, TargetPhoneNumberRepository,
dialer_rr_config_key, reserve_sales_rep_round_robin_batch,
select_next_sales_rep_round_robin
There is no CampaignTargetRepository. CampaignTarget is mapped as a model and written
through the base upsert() by morpheus-hubspot directly.
Adding a repository
-
Create
repositories/<name>.pywithclass XRepository(BaseRepository[X])andmodel_class = X. -
Add only the queries a handler actually needs. Ad-hoc filtering belongs in
filter_by(). -
Take
session: Sessionfirst, positionally; make everything else keyword-only. -
Never commit, never open a session, never catch and swallow a database error.
-
Use camelCase column names in
conflict_columnsandupdate_columns. -
Export it from
repositories/init.py. -
Update this page and morpheus-data-layer.