One ingestion system that pulls "ships" from many sources (events, videos, blogs, demos, workshops, recipes) and upserts them into the DevRel Ships Notion database β so the bi-weekly report is a saved view, not a writing task.
A thin app on durable-sync:
the generic Temporal sync engine (durable entity workflow, idempotent upsert,
unattended OAuth refresh, rate-limit backoff) is the library; this repo is just
the connectors + the DevRel-specific mapping and roster matching.
Most DevRel teams don't own their company's Notion workspace, so they can't create the internal integration token the REST API requires. So we talk to Notion through its hosted MCP server with OAuth-as-an-individual + dynamic client registration: authorize once in a browser, as yourself β no admin, no IT ticket.
A long-running Temporal workflow (OAuthTokenWorkflow, in the library) owns the
refresh token and serves fresh access tokens via query, so the worker runs
unattended and the rotation race can't happen.
ONE-TIME (browser, as yourself) ALWAYS-ON PER SOURCE (rolling)
notion.bootstrap β refresh_token ββΊ OAuthTokenWorkflow ββqueryβ sync_records (MCP)
(refreshes on a timer) β²
SourceSyncWorkflow
ββ ShipsSource.fetch
devrel_ships/
βββ config.py # property names, data-source IDs, TYPE_ICONS, secrets
βββ models.py # ShipInput β a connector's normalized shape (pre-Record)
βββ ships_source.py # ShipsSource (durable_sync Source): ShipInput -> Record
βββ roster_enrich.py # roster matching as the destination-side session_enrich hook
βββ pipeline.py # wires ShipsSource -> NotionDestination (the one retarget point)
βββ connectors/ # the ONLY source-specific code
β βββ registry.py # source name -> fetch fn (CONNECTORS)
β βββ luma/ # implemented: client.py + fetch.py (+ pure _to_ship)
β βββ contentful/ # implemented: scoped by content type
β βββ youtube/ # stub
βββ worker.py # thin: run_worker(SOURCE, DESTINATION)
βββ bootstrap.py # thin: start_sources(SOURCE) β one entity workflow per connector
tests/ # connector normalizers + the retarget tests (roster_enrich, to_record)
1. Install the engine β pip install -e ../durable-sync (or pip install -e ".[dev]").
2. Notion data source IDs β put them in .env as NOTION_DEVREL_SHIPS_DS /
NOTION_ROSTER_DS. Idempotency keys on the existing Source ID column (no new
column). Manual "Third Party" rows keep Source ID blank β never touched.
3. Luma API key β Luma calendar β Settings β API keys β Create key (needs Luma Plus).
pip install -e ../durable-sync
cp .env.example .env # data-source IDs + LUMA_API_KEY (+ CONTENTFUL_*)
temporal server start-dev # terminal 1
# one-time Notion auth (no admin), from the library:
python -m durable_sync.connectors.notion.bootstrap # opens browser, consent as yourself
python -m durable_sync.connectors.notion.prove # OPTIONAL: headless proof
python -m devrel_ships.worker # terminal 2
python -m durable_sync.connectors.notion.start # launch the token-owner workflow (once)
python -m devrel_ships.bootstrap # terminal 3: start one entity workflow per connector
pytestEncrypt payloads at rest (recommended for shared/Cloud clusters):
python -m durable_sync.codec # prints a key; put it in .env as DURABLE_SYNC_ENC_KEY- The generic engine is a dependency, not copy-paste. Dedup, roster-relation writes, OAuth, backoff live once in durable-sync; this repo can't reinvent them.
connectors/<source>/is the only per-source code: a client + a pure_to_shipnormalizer.ships_source.pyturns those intoRecords.- Roster matching is the destination-side
session_enrichseam β it needs the open Notion session (to read Team Members), which is exactly what that library hook provides.
connectors/<source>/fetch.pyβ a plainfetch(after_iso) -> list[ShipInput](NO@activity.defn) plus a pure normalize function.- Add one line to
connectors/registry.py(CONNECTORS). - Unit-test the normalizer in
tests/. Upsert, dedup, roster scoping, scheduling come from the library +pipeline.py.
Contentful note: scoped by content type (config.CONTENTFUL_DEVREL_TYPES),
not per-author β DevRel-owned types are ingested wholesale; Marketing-shared types
are never fetched.
- Generic hosts (e.g. "Temporal Team") match no roster person β Team Member left
empty β excluded from the Bi-Weekly Report.
Source Authoris always written. (The per-rununmatched_ownercount from the old shape isn't surfaced anymore.) - The OAuth path authorizes as an individual: if that person loses Notion access,
re-run the library's
notion.bootstrap+notion.start. - Two Bi-Weekly Report filters are Notion-UI-only: Ship Date within past 2 weeks and Emeritus? is unchecked.