Crosswake Adoption Guide
View SourceThis guide shows the shipped flashcard proof first, then turns it into a route-local recipe for Phoenix SaaS teams.
Crosswake is not asking you to make the whole app local-first. The proof path is
one explicit owner decision: /offline is an :offline_island route whose local
work is owned by app JavaScript and reconciled by Phoenix/Ecto.
For the setup commands, start with examples/QUICK_START.md. For route-owner selection, read guides/route_policy.md and guides/web_to_mobile_migration.md.
Proof Walkthrough
The checked-in proof is the flashcard study island:
- Route:
/offline - Route owner:
:offline_island - HTML entry point:
CrosswakeExample.OfflineController - Browser island code:
examples/phoenix_host/priv/static/offline_study.js - Sync endpoint:
/study/sync - API controller:
CrosswakeExample.LocalFirst.SyncController - Reconciliation context:
CrosswakeExample.LocalFirst.Study.sync_events/1 - Ecto schema:
CrosswakeExample.LocalFirst.ReviewEvent
Run the proof from examples/phoenix_host:
npm ci
npx playwright install chromium
npx playwright test e2e/offline_sync.spec.ts
That Playwright test opens /offline, drives the real UI, observes IndexedDB,
reconnects the browser, waits for /study/sync, checks Ecto state, proves the
accepted local record is removed from the outbox, and verifies duplicate replay
is idempotent.
Current Data Path
The current implementation path is intentionally small and inspectable:
User rates a card on /offline
-> offline_study.js handleReview(rating)
-> queueMutation(...)
-> IndexedDB mutations store
-> window online event or foreground route activity
-> flushOutbox()
-> POST /study/sync
-> CrosswakeExample.LocalFirst.SyncController.sync/2
-> CrosswakeExample.LocalFirst.Study.sync_events/1
-> CrosswakeExample.LocalFirst.ReviewEvent.changeset/2
-> Ecto insert_all(... on_conflict: :nothing ...)
-> accepted_records deleted from the local outboxqueueMutation stores small semantic events, not UI state snapshots. Each event
contains:
client_mutation_idcard_idrating
flushOutbox reads queued mutations from IndexedDB and posts them to
/study/sync. The browser online event triggers flushOutbox; the route also
tries an eager flush while the user is online. This is reconnect-triggered,
route-local replay, not a background service for the whole app.
On the Phoenix side, SyncController.sync/2 accepts a batch and delegates to
Study.sync_events/1. ReviewEvent.changeset/2 validates required fields and
rating values. Valid rows are inserted with insert_all using
on_conflict: :nothing and conflict_target: :client_mutation_id, so duplicate
replay does not create duplicate canonical rows.
Replay Outcomes
Use these outcome words precisely:
- accepted - Phoenix/Ecto validated and persisted the event. The browser deletes the matching accepted record from the IndexedDB outbox.
- rejected - Phoenix/Ecto rejected the event with validation errors. The record stays visible/queued locally so the app can show the failure and decide what to do next.
- duplicate-idempotent - replaying the same
client_mutation_idis accepted by the API shape but inserts no new row because Ecto useson_conflict: :nothing. - conflict - canonical Crosswake replay vocabulary for a server-side state
disagreement that needs attention. The current
/study/syncdemo teaches the outcome class, but it does not ship a full conflict-resolution UI.
The browser status copy should stay plain: queued locally, syncing, synced count, queued count, or sync failed with local records retained.
Reusable Offline-Island Recipe
Use this pattern only for a route that truly owns local mutation and replay. Cached read-only routes are covered in guides/offline.md.
1. Declare the route owner
Choose :offline_island only for the route that owns local work:
get("/offline", CrosswakeExample.OfflineController, :index,
crosswake: [
id: "offline-study",
runtime: :offline_island,
offline: :local_first,
security: :standard
]
)Nearby routes can stay :live_view or cached read-only. Crosswake works best
when each route owner is explicit.
2. Build the island
Render a small route-owned island that can run without a LiveView socket. The
checked-in proof uses CrosswakeExample.OfflineController and a static module
script loaded by offline_html/index.html.heex.
Keep the island focused on the user job. For the flashcard proof, the user rates one card and gets status about local save and replay.
3. Persist semantic events
Store small semantic events in local browser storage. In the proof, IndexedDB has
separate stores for flashcards and mutations.
Prefer durable event fields that Phoenix can validate:
- app-generated mutation id
- stable domain id
- user action or rating
- enough route/session context for reconciliation
Do not store broad UI snapshots and call that sync. Do not make the bounded bridge own local writes.
4. Flush on reconnect or explicit sync
Trigger replay when the route is active and the browser is online. The current
proof uses the window online event and an eager foreground flush.
If the network request fails, keep queued records in the outbox and show a clear local status. Do not silently drop the work.
5. Reconcile in Phoenix/Ecto
Keep canonical truth on the Phoenix side:
- Receive batched events at a route-local endpoint such as
/study/sync. - Validate each event with an Ecto changeset.
- Insert valid events idempotently by client mutation id.
- Return accepted records and rejected records separately.
- Leave conflicts explicit instead of silently overwriting server truth.
The browser deletes only accepted records. Rejected or conflict outcomes stay visible until the app can guide the user.
6. Test the whole loop
The useful proof is end-to-end:
- user action queues an IndexedDB event
- reconnect triggers app code
- Phoenix receives
/study/sync - Ecto persists one canonical row
- duplicate replay stays idempotent
- accepted outbox records are deleted
- rejected records remain inspectable
Use Playwright or an equivalent browser E2E to prove that flow. Unit tests alone do not prove route ownership or reconnect behavior.
Bridge Boundary
The bounded bridge remains a low-frequency request/reply affordance for a Phoenix-owned route. It can help with a semantic native action such as Share, but it does not own offline writes, replay, or reconciliation.
If a flow needs continuous client authority, move it toward an offline island or a native screen. Do not push that authority through bridge messages.
What This Does Not Prove
- No whole-app local-first claim.
- No whole-app background replay claim.
- No bridge authority over local writes.
- No native/device/provider authority for the offline path.
- No full conflict-resolution UI in the current
/study/syncdemo. - No screenshots, recordings, artifact manifests, or native evidence promotion in this phase.
Reference Map
- examples/QUICK_START.md - runnable commands and proof ladder
- guides/route_policy.md - route-owner decisions
- guides/web_to_mobile_migration.md - existing Phoenix SaaS route inventory
- guides/offline.md - cached read-only versus offline island
- guides/bridge.md - bounded bridge contract and denials
- guides/support_matrix.md - support-truth labels