First PUBLIC streaming surface on ``Pyrx``. Native iOS apps can now
subscribe to a closed taxonomy of SDK events (``PyrxEvent``) without
re-implementing ``UNUserNotificationCenter`` delegation or polling
SDK state. The React Native bridge in ``@pyrx/synapse-react-native``
will collect from this same surface in Phase 9.2.1 PR-3, making
``usePushReceived`` / ``usePushClicked`` / ``useDeepLink`` /
``useIdentityChanged`` functional in v0.2.0.
Per the Phase 9.2.1 plan (in upstream ``pyrx.synapse``,
``docs/plans/phase-9.2.1-native-callback-observers-plan-2026-06-27.md``).
## Public surface
* ``Pyrx.shared.observe(on:_:) -> PyrxObserverToken`` — closure-based
observer registration. Multi-subscriber; tokens are independent;
handler runs on the queue passed as ``on:`` (default ``.main``).
* ``Pyrx.shared.events() -> AsyncStream<PyrxEvent>`` — AsyncStream
sugar for Swift Concurrency-first callers. Each call returns a
fresh stream; cancelling the consuming ``Task`` cancels the
underlying token.
* ``PyrxEvent`` enum with 5 cases (D4):
- ``.pushReceived(PushReceivedEvent)``
- ``.pushClicked(PushClickedEvent)`` (NOT cold-start)
- ``.pushReceivedColdStart(PushReceivedEvent)``
- ``.queueDrained(count: Int)`` (only when count > 0)
- ``.identityChanged(before: IdentitySnapshot, after: IdentitySnapshot)``
* ``PyrxObserverToken`` — opaque reference-type handle with idempotent
``cancel()``. Holding the token keeps the subscription alive; drop
the only reference and the deinit cancels.
* ``PyrxAttributeValue`` — typealias for the already-public
``JSONValue`` (NOT a rename — preserves source-compat with existing
``identify(traits:)`` / ``track(properties:)`` callers).
* ``IdentitySnapshot`` — ``(anonymousId, externalId, snapshotAt)``.
Built from ``PyrxStorage`` reads immediately before/after each
identity mutation. Both ``before`` and ``after`` are non-Optional
on ``.identityChanged`` (anonymous-user is a state).
## Implementation
* ``Sources/PYRXSynapse/Observer/`` — 7 new files for the public
types + the actor-isolated registry.
* ``Sources/PYRXSynapse/Pyrx+Observer.swift`` — extension exposing
the two public observer methods.
* Fire-points wired (additive):
- ``PushHandlers.recordPushReceived`` → ``.pushReceived``
- ``PushHandlers.emitOpened`` / ``emitClicked`` → ``.pushClicked``
- ``PushHandlers.recordColdStartOpen`` → ``.pushReceivedColdStart``
- ``IdentityManager.identify`` / ``alias`` / ``logout`` →
``.identityChanged(before:after:)``
- ``EventQueue.drainLoop`` → ``.queueDrained(count:)`` via injected
``onDrainComplete`` callback at construction time
* ``parseAlert`` helper in ``PushHandlers.swift`` extracts
``(title, body)`` from ``userInfo["aps"]["alert"]``, supporting
both string and dict alert shapes per APNs.
* **Cold-start dedup** (non-negotiable invariant): when iOS delivers
a notification tap via BOTH ``launchOptions[.remoteNotification]``
AND ``userNotificationCenter(_:didReceive:)``, the publisher
dedups by ``push_log_id`` within a 5s window so
``.pushReceivedColdStart`` fires exactly once AND ``.pushClicked``
does NOT fire for the cold-start payload. ``ColdStartDedupTests``
proves this.
* **Replay buffer of 4** — late subscribers receive the most-recent
4 events. Documented contract.
## Test coverage
* 30+ new XCTest cases in ``Tests/PYRXSynapseTests/Observer/`` across
5 files: ``ObserverRegistryTests``, ``EventStreamTests``,
``FirePointsTests``, ``IdentityEventsTests``, ``ColdStartDedupTests``.
* Full suite: **216/216 pass** (no regression — verify via
``swift test``).
## Sample app
* New 6th tab in ``Examples/SwiftUIDemo/SwiftUIDemo/`` — ``Observer``
tab demonstrates ``Pyrx.shared.events()`` via SwiftUI's ``.task``
modifier, shows received events as a scrollable list.
## Docs
* ``docs/observers.md`` — API reference + lifecycle + cold-start
dedup + replay buffer + forward-compat + error semantics.
* ``README.md`` — "What's new in 0.1.2" callout + new entry in the
Documentation table + observers bullet in Features.
## Forward-compatibility
``PyrxEvent`` is non-``@frozen`` for source consumers (SPM, CocoaPods).
Adding cases in future minor versions is source-compatible — existing
``switch`` statements continue to compile, with a Swift warning to
handle the new case.
Library evolution (``-enable-library-evolution`` / ``BUILD_LIBRARY_FOR_DISTRIBUTION``)
is NOT enabled in 0.1.2 — deferred to whenever we first ship binary
xcframework distribution (or 1.0). Until then, the resilience contract
only applies to source consumers; binary consumers (none today) would
treat the enum as ``@frozen``. Documented in ``docs/observers.md``.
## Cross-repo state
This PR is part of a coordinated Phase 9.2.1 release:
* PR-1 (this PR): iOS observer API → publishes as PYRXSynapse 0.1.2
* PR-2: Android observer SharedFlow → publishes as
``tech.pyrx.synapse:synapse-{core,push}:0.1.4``
(in flight in parallel, ``pyrx-synapse-android`` repo)
* PR-3: RN bridge wiring → publishes as
``@pyrx/synapse-react-native@0.2.0`` (opens as DRAFT against
``link:``-resolved local SPM/Maven paths once PR-1 + PR-2 review)
* PR-4: monorepo banner / ADR-0005 / docs cross-links
Reviewable independently of PR-2 (different review surface). Merge +
publish to SPM + CocoaPods Trunk before PR-3 flips from DRAFT to READY.
## Version
``PYRXSynapse 0.1.1 → 0.1.2`` via ``scripts/bump-version.sh 0.1.2``
(podspec + ``PyrxConstants.sdkVersion`` + hardcoded test assertion in
``PushRegistrationTests.swift`` all in lockstep). Bump-script confirmed
``swift test`` clean post-bump (216/216).
## Resolutions applied from preflight
The previous agent's ``feature-dev:code-explorer`` pass surfaced 8
plan-vs-reality conflicts. Resolutions (full rationale in the upstream
``pyrx.synapse`` PR thread):
1. ``IdentitySnapshot`` shape: ``(anonymousId, externalId, snapshotAt)``
— NOT ``IdentityResult`` (which is an RPC receipt, not a snapshot).
2. ``before``/``after`` non-Optional on ``.identityChanged`` (anonymous
user is a state).
3. ``PyrxAttributeValue`` is a ``typealias JSONValue`` (NOT a rename —
``JSONValue`` already public since 0.1.0).
4. Library evolution deferred — see Forward-compatibility above.
5. Test layout: ``Tests/PYRXSynapseTests/Observer/`` subfolder.
6. Sample app at ``Examples/SwiftUIDemo/SwiftUIDemo/ObserverDemoView.swift``
(flat, matches convention); 6th tab via ``ContentView.swift``.
7. ``queueDrained`` plumbing: ``onDrainComplete`` callback injected
at ``EventQueue.init`` time, default ``{ _ in }`` for backward
compat.
8. APNs ``aps.alert`` parser added for ``PushReceivedEvent.title`` +
``body`` (supports both string and dict shapes).
Co-authored-by: trieutruong <trieutruong.dev@gmail.com>