Phase 7c + 7d: optimistic data updates and offline mutation queue
7c — Optimistic Data Updates: - orchestration.sx: optimistic-cache-update/revert/confirm + submit-mutation - pages.py: mount_action_endpoint at /sx/action/<name> for client mutations - optimistic-demo.sx: live demo with todo list, pending/confirmed/reverted states - helpers.py: demo data + add-demo-item action handler 7d — Offline Data Layer: - orchestration.sx: connectivity tracking, offline-queue-mutation, offline-sync, offline-aware-mutation (routes online→submit, offline→queue) - offline-demo.sx: live demo with notes, connectivity indicator, sync timeline - helpers.py: offline demo data Also updates plans.sx: marks Phase 7 fully complete (all 6 sub-phases 7a-7f). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,7 +135,9 @@
|
||||
(dict :label "Data Test" :href "/isomorphism/data-test")
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")
|
||||
(dict :label "Streaming" :href "/isomorphism/streaming")
|
||||
(dict :label "Affinity" :href "/isomorphism/affinity")))
|
||||
(dict :label "Affinity" :href "/isomorphism/affinity")
|
||||
(dict :label "Optimistic" :href "/isomorphism/optimistic")
|
||||
(dict :label "Offline" :href "/isomorphism/offline")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Status" :href "/plans/status"
|
||||
|
||||
76
sx/sx/offline-demo.sx
Normal file
76
sx/sx/offline-demo.sx
Normal file
@@ -0,0 +1,76 @@
|
||||
;; Offline data layer demo — exercises Phase 7d offline mutation queue.
|
||||
;;
|
||||
;; Shows connectivity status, note list, and offline mutation queue.
|
||||
;; When offline, mutations are queued locally. On reconnect, they sync.
|
||||
;;
|
||||
;; Open browser console and look for:
|
||||
;; "sx:offline queued" — mutation added to queue while offline
|
||||
;; "sx:offline syncing" — reconnected, replaying queued mutations
|
||||
;; "sx:offline synced" — individual mutation confirmed by server
|
||||
|
||||
(defcomp ~offline-demo-content (&key notes server-time)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Offline Data Layer")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page tests Phase 7d offline capabilities. Mutations made while "
|
||||
"offline are queued locally and replayed when connectivity returns."))
|
||||
|
||||
;; Connectivity indicator
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Status")
|
||||
(dl :class "grid grid-cols-2 gap-2 text-sm"
|
||||
(dt :class "font-medium text-stone-600" "Server time")
|
||||
(dd :class "font-mono text-stone-900" server-time)
|
||||
(dt :class "font-medium text-stone-600" "Notes count")
|
||||
(dd :class "text-stone-900" (str (len notes)))
|
||||
(dt :class "font-medium text-stone-600" "Connectivity")
|
||||
(dd :class "text-stone-900"
|
||||
(span :id "offline-status" :class "inline-flex items-center gap-1.5"
|
||||
(span :class "w-2 h-2 rounded-full bg-green-500")
|
||||
"Online"))))
|
||||
|
||||
;; Note list
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Notes")
|
||||
(div :id "offline-notes" :class "space-y-2"
|
||||
(map (fn (note)
|
||||
(div :class "flex items-center justify-between rounded border border-stone-100 bg-white p-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(span :class "flex-none rounded-full bg-blue-100 text-blue-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (get note "id")))
|
||||
(span :class "text-stone-900" (get note "text")))
|
||||
(span :class "text-xs text-stone-400 font-mono"
|
||||
(get note "created"))))
|
||||
notes)))
|
||||
|
||||
;; Architecture
|
||||
(div :class "space-y-4"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "How it works")
|
||||
(div :class "space-y-2"
|
||||
(map-indexed
|
||||
(fn (i step)
|
||||
(div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3"
|
||||
(span :class "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (+ i 1)))
|
||||
(div
|
||||
(div :class "font-medium text-stone-900" (get step "label"))
|
||||
(div :class "text-sm text-stone-500" (get step "detail")))))
|
||||
(list
|
||||
(dict :label "Online mutation" :detail "Routes through submit-mutation (Phase 7c) — optimistic predict, server confirm/revert")
|
||||
(dict :label "Offline detection" :detail "Browser 'offline' event sets _is-online to false. offline-aware-mutation routes to queue")
|
||||
(dict :label "Queue mutation" :detail "Mutation stored in _offline-queue with 'pending' status. Optimistic update applied locally")
|
||||
(dict :label "Reconnect" :detail "Browser 'online' event triggers offline-sync — replays queue in order via execute-action")
|
||||
(dict :label "Sync result" :detail "Each mutation marked 'synced' or 'failed'. Failed mutations stay for manual retry")))))
|
||||
|
||||
;; How to test
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to test offline behavior")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console and Network tab")
|
||||
(li "Navigate to this page via client-side routing")
|
||||
(li "In DevTools, set Network to \"Offline\" mode")
|
||||
(li "The connectivity indicator should change to red/Offline")
|
||||
(li "Watch console for " (code :class "bg-amber-100 px-1 rounded" "sx:offline queued"))
|
||||
(li "Re-enable network — watch for " (code :class "bg-amber-100 px-1 rounded" "sx:offline syncing"))
|
||||
(li "Queued mutations replay and confirm or fail")))))
|
||||
81
sx/sx/optimistic-demo.sx
Normal file
81
sx/sx/optimistic-demo.sx
Normal file
@@ -0,0 +1,81 @@
|
||||
;; Optimistic update demo — exercises Phase 7c client-side predicted mutations.
|
||||
;;
|
||||
;; This page shows a todo list with optimistic add/remove.
|
||||
;; Mutations are predicted client-side, sent to server, and confirmed/reverted.
|
||||
;;
|
||||
;; Open browser console and look for:
|
||||
;; "sx:optimistic confirmed" — server accepted the mutation
|
||||
;; "sx:optimistic reverted" — server rejected, data rolled back
|
||||
|
||||
(defcomp ~optimistic-demo-content (&key items server-time)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Optimistic Updates")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page tests Phase 7c optimistic data mutations. Items are updated "
|
||||
"instantly on the client, then confirmed or reverted when the server responds."))
|
||||
|
||||
;; Server metadata
|
||||
(div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Current state")
|
||||
(dl :class "grid grid-cols-2 gap-2 text-sm"
|
||||
(dt :class "font-medium text-stone-600" "Server time")
|
||||
(dd :class "font-mono text-stone-900" server-time)
|
||||
(dt :class "font-medium text-stone-600" "Item count")
|
||||
(dd :class "text-stone-900" (str (len items)))))
|
||||
|
||||
;; Item list
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Items")
|
||||
(div :id "optimistic-items" :class "space-y-2"
|
||||
(map (fn (item)
|
||||
(div :class "flex items-center justify-between rounded border border-stone-100 bg-white p-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(span :class "flex-none rounded-full bg-violet-100 text-violet-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (get item "id")))
|
||||
(span :class "text-stone-900" (get item "label")))
|
||||
(span :class "text-xs px-2 py-0.5 rounded-full"
|
||||
:class (case (get item "status")
|
||||
"confirmed" "bg-green-100 text-green-700"
|
||||
"pending" "bg-amber-100 text-amber-700"
|
||||
"reverted" "bg-red-100 text-red-700"
|
||||
:else "bg-stone-100 text-stone-500")
|
||||
(get item "status"))))
|
||||
items))
|
||||
|
||||
;; Add button — triggers optimistic mutation
|
||||
(div :class "pt-2"
|
||||
(button :class "px-4 py-2 bg-violet-500 text-white rounded hover:bg-violet-600 text-sm"
|
||||
:sx-post "/sx/action/add-demo-item"
|
||||
:sx-target "#main-panel"
|
||||
:sx-vals "{\"label\": \"New item\"}"
|
||||
"Add item (optimistic)")))
|
||||
|
||||
;; How it works
|
||||
(div :class "space-y-4"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "How it works")
|
||||
(div :class "space-y-2"
|
||||
(map-indexed
|
||||
(fn (i step)
|
||||
(div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3"
|
||||
(span :class "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold"
|
||||
(str (+ i 1)))
|
||||
(div
|
||||
(div :class "font-medium text-stone-900" (get step "label"))
|
||||
(div :class "text-sm text-stone-500" (get step "detail")))))
|
||||
(list
|
||||
(dict :label "Predict" :detail "Client applies mutator function to cached data immediately")
|
||||
(dict :label "Snapshot" :detail "Pre-mutation data saved in _optimistic-snapshots for rollback")
|
||||
(dict :label "Re-render" :detail "Page content re-evaluated and swapped with predicted data")
|
||||
(dict :label "Submit" :detail "Mutation sent to server via POST /sx/action/<name>")
|
||||
(dict :label "Confirm or revert" :detail "Server responds — cache updated with truth, or reverted to snapshot")))))
|
||||
|
||||
;; How to verify
|
||||
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
|
||||
(p :class "font-semibold text-amber-800" "How to verify")
|
||||
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
|
||||
(li "Open the browser console (F12)")
|
||||
(li "Navigate to this page from another isomorphism page")
|
||||
(li "Click \"Add item\" — item appears instantly with \"pending\" status")
|
||||
(li "Watch console for " (code :class "bg-amber-100 px-1 rounded" "sx:optimistic confirmed"))
|
||||
(li "Item status changes to \"confirmed\" when server responds")))))
|
||||
@@ -1393,12 +1393,12 @@
|
||||
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. ~suspense component renders fallbacks, inline resolution scripts fill in content. Concurrent IO via asyncio, chunked transfer encoding.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Demo: " (a :href "/isomorphism/streaming" "/isomorphism/streaming")))
|
||||
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-stone-500 text-white uppercase" "Not Started")
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
|
||||
(a :href "/isomorphism/" :class "font-semibold text-stone-800 underline" "Isomorphic Phase 7: Full Isomorphism"))
|
||||
(p :class "text-sm text-stone-600" "Runtime boundary optimizer, affinity annotations, offline data layer via Service Worker + IndexedDB, isomorphic testing harness.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Depends on: all previous phases."))))))
|
||||
(p :class "text-sm text-stone-600" "Affinity annotations, render plans, optimistic data updates, offline mutation queue, isomorphic testing harness, universal page descriptor.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "All 6 sub-phases (7a–7f) complete."))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Fragment Protocol
|
||||
@@ -2040,10 +2040,61 @@
|
||||
(li "Client page registry includes :render-plan for each page"))))
|
||||
|
||||
(~doc-subsection :title "7c. Optimistic Data Updates"
|
||||
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection."))
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Client-side predicted mutations with snapshot rollback. Mutations are applied instantly, sent to the server, and confirmed or reverted."))
|
||||
|
||||
(p "Extends the Phase 4 data cache with optimistic mutation support. The client predicts the result of a mutation, immediately re-renders with the predicted data, and sends the mutation to the server. If the server accepts, the snapshot is discarded. If the server rejects, the data is rolled back to the pre-mutation snapshot and the page re-renders.")
|
||||
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (strong "optimistic-cache-update") " — applies a mutator function to cached data, saves a snapshot for rollback, re-renders with predicted data")
|
||||
(li (strong "optimistic-cache-revert") " — restores the pre-mutation snapshot if the server rejects")
|
||||
(li (strong "optimistic-cache-confirm") " — discards the snapshot after server confirmation")
|
||||
(li (strong "submit-mutation") " — orchestration function: predict, submit, confirm/revert")
|
||||
(li (strong "/sx/action/<name>") " — server endpoint for processing mutations (POST, returns SX wire format)"))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/ref/orchestration.sx — optimistic cache functions and submit-mutation spec")
|
||||
(li "shared/sx/pages.py — mount_action_endpoint for /sx/action/<name>")
|
||||
(li "sx/sx/optimistic-demo.sx — live demo component")
|
||||
(li "sx/sxc/pages/helpers.py — demo data + action handlers")))
|
||||
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Live demo at " (a :href "/isomorphism/optimistic" :class "text-violet-600 hover:underline" "/isomorphism/optimistic"))
|
||||
(li "Console log: " (code "sx:optimistic confirmed") " / " (code "sx:optimistic reverted"))
|
||||
(li "Items show pending/confirmed/reverted status badges"))))
|
||||
|
||||
(~doc-subsection :title "7d. Offline Data Layer"
|
||||
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
|
||||
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
|
||||
(p :class "text-green-800 text-sm" "Connectivity tracking and offline mutation queue. Mutations made while offline are queued locally and replayed when connectivity returns."))
|
||||
|
||||
(p "Builds on Phase 7c's optimistic updates with offline awareness. The " (code "offline-aware-mutation") " function routes to " (code "submit-mutation") " when online, or " (code "offline-queue-mutation") " when offline. On reconnect, " (code "offline-sync") " replays the queue in order.")
|
||||
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (strong "Connectivity tracking") " — browser online/offline events update " (code "_is-online") " state")
|
||||
(li (strong "offline-queue-mutation") " — stores mutation in " (code "_offline-queue") " with pending status, applies optimistic update locally")
|
||||
(li (strong "offline-sync") " — on reconnect, replays pending mutations via " (code "execute-action") ", marks each synced or failed")
|
||||
(li (strong "offline-aware-mutation") " — top-level function: online → " (code "submit-mutation") ", offline → " (code "offline-queue-mutation"))
|
||||
(li (strong "offline-pending-count") " — returns count of unsynced mutations"))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/ref/orchestration.sx — offline queue, sync, connectivity tracking")
|
||||
(li "sx/sx/offline-demo.sx — live demo component")
|
||||
(li "sx/sxc/pages/helpers.py — demo data helper")))
|
||||
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Live demo at " (a :href "/isomorphism/offline" :class "text-violet-600 hover:underline" "/isomorphism/offline"))
|
||||
(li "Test with DevTools Network → Offline mode")
|
||||
(li "Console log: " (code "sx:offline queued") ", " (code "sx:offline syncing") ", " (code "sx:offline synced")))))
|
||||
|
||||
(~doc-subsection :title "7e. Isomorphic Testing"
|
||||
|
||||
|
||||
@@ -525,6 +525,30 @@
|
||||
:data (affinity-demo-data)
|
||||
:content (~affinity-demo-content :components components :page-plans page-plans))
|
||||
|
||||
(defpage optimistic-demo
|
||||
:path "/isomorphism/optimistic"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Optimistic")
|
||||
:selected "Optimistic")
|
||||
:data (optimistic-demo-data)
|
||||
:content (~optimistic-demo-content :items items :server-time server-time))
|
||||
|
||||
(defpage offline-demo
|
||||
:path "/isomorphism/offline"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Offline")
|
||||
:selected "Offline")
|
||||
:data (offline-demo-data)
|
||||
:content (~offline-demo-content :notes notes :server-time server-time))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
|
||||
@@ -28,6 +28,9 @@ def _register_sx_helpers() -> None:
|
||||
"run-modular-tests": _run_modular_tests,
|
||||
"streaming-demo-data": _streaming_demo_data,
|
||||
"affinity-demo-data": _affinity_demo_data,
|
||||
"optimistic-demo-data": _optimistic_demo_data,
|
||||
"action:add-demo-item": _add_demo_item,
|
||||
"offline-demo-data": _offline_demo_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -922,3 +925,45 @@ def _affinity_demo_data() -> dict:
|
||||
})
|
||||
|
||||
return {"components": components, "page-plans": page_plans}
|
||||
|
||||
|
||||
def _optimistic_demo_data() -> dict:
|
||||
"""Return demo data for the optimistic update test page."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{"id": 1, "label": "First item", "status": "confirmed"},
|
||||
{"id": 2, "label": "Second item", "status": "confirmed"},
|
||||
{"id": 3, "label": "Third item", "status": "confirmed"},
|
||||
],
|
||||
"server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
def _add_demo_item(**kwargs) -> dict:
|
||||
"""Action: add a demo item. Returns confirmation with new item."""
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
label = kwargs.get("label", "Untitled")
|
||||
return {
|
||||
"id": random.randint(100, 9999),
|
||||
"label": label,
|
||||
"status": "confirmed",
|
||||
"added-at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
def _offline_demo_data() -> dict:
|
||||
"""Return demo data for the offline data layer test page."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return {
|
||||
"notes": [
|
||||
{"id": 1, "text": "First note", "created": "2026-03-08T10:00:00Z"},
|
||||
{"id": 2, "text": "Second note", "created": "2026-03-08T11:30:00Z"},
|
||||
{"id": 3, "text": "Third note", "created": "2026-03-08T14:15:00Z"},
|
||||
],
|
||||
"server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user