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:
@@ -884,6 +884,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
|
||||
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
|
||||
mount_io_endpoint(app, service_name)
|
||||
|
||||
# Mount action endpoint for Phase 7c: optimistic data mutations
|
||||
mount_action_endpoint(app, service_name)
|
||||
|
||||
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
names: set[str] | list[str] | None = None) -> None:
|
||||
@@ -1186,3 +1189,56 @@ def mount_io_endpoint(app: Any, service_name: str) -> None:
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO))
|
||||
|
||||
|
||||
def mount_action_endpoint(app: Any, service_name: str) -> None:
|
||||
"""Mount /sx/action/<name> endpoint for client-side data mutations.
|
||||
|
||||
The client can POST to trigger a named action (registered via
|
||||
register_page_helpers with an 'action:' prefix). The action receives
|
||||
the JSON payload, performs the mutation, and returns the new page data
|
||||
as SX wire format.
|
||||
|
||||
This is the server counterpart to submit-mutation in orchestration.sx.
|
||||
"""
|
||||
from quart import make_response, request, abort as quart_abort
|
||||
from .parser import serialize
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
@csrf_exempt
|
||||
async def action_handler(name: str) -> Any:
|
||||
# Look up action helper
|
||||
helpers = get_page_helpers(service_name)
|
||||
action_fn = helpers.get(f"action:{name}")
|
||||
if action_fn is None:
|
||||
quart_abort(404)
|
||||
|
||||
# Parse JSON payload
|
||||
import asyncio as _asyncio
|
||||
data = await request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
result = action_fn(**data)
|
||||
if _asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
except Exception as e:
|
||||
logger.warning("Action %s failed: %s", name, e)
|
||||
resp = await make_response(f'(dict "error" "{e}")', 500)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
return resp
|
||||
|
||||
result_sx = serialize(result) if result is not None else "nil"
|
||||
resp = await make_response(result_sx, 200)
|
||||
resp.content_type = "text/sx; charset=utf-8"
|
||||
return resp
|
||||
|
||||
action_handler.__name__ = "sx_action"
|
||||
action_handler.__qualname__ = "sx_action"
|
||||
|
||||
app.add_url_rule(
|
||||
"/sx/action/<name>",
|
||||
endpoint="sx_action",
|
||||
view_func=action_handler,
|
||||
methods=["POST"],
|
||||
)
|
||||
logger.info("Mounted action endpoint for %s at /sx/action/<name>", service_name)
|
||||
|
||||
@@ -589,6 +589,139 @@
|
||||
{"data" data "ts" (now-ms)})))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Optimistic data updates (Phase 7c)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side predicted mutations with rollback.
|
||||
;; submit-mutation applies a predicted update immediately, sends the mutation
|
||||
;; to the server, and either confirms or reverts based on the response.
|
||||
|
||||
(define _optimistic-snapshots (dict))
|
||||
|
||||
(define optimistic-cache-update
|
||||
(fn (cache-key mutator)
|
||||
;; Apply predicted mutation to cached data. Saves snapshot for rollback.
|
||||
;; Returns predicted data or nil if no cached data exists.
|
||||
(let ((cached (page-data-cache-get cache-key)))
|
||||
(when cached
|
||||
(let ((predicted (mutator cached)))
|
||||
;; Save original for revert
|
||||
(dict-set! _optimistic-snapshots cache-key cached)
|
||||
;; Update cache with prediction
|
||||
(page-data-cache-set cache-key predicted)
|
||||
predicted)))))
|
||||
|
||||
(define optimistic-cache-revert
|
||||
(fn (cache-key)
|
||||
;; Revert to pre-mutation snapshot. Returns restored data or nil.
|
||||
(let ((snapshot (get _optimistic-snapshots cache-key)))
|
||||
(when snapshot
|
||||
(page-data-cache-set cache-key snapshot)
|
||||
(dict-delete! _optimistic-snapshots cache-key)
|
||||
snapshot))))
|
||||
|
||||
(define optimistic-cache-confirm
|
||||
(fn (cache-key)
|
||||
;; Server accepted — discard the rollback snapshot.
|
||||
(dict-delete! _optimistic-snapshots cache-key)))
|
||||
|
||||
(define submit-mutation
|
||||
(fn (page-name params action-name payload mutator-fn on-complete)
|
||||
;; Optimistic mutation: predict locally, send to server, confirm or revert.
|
||||
;; on-complete is called with "confirmed" or "reverted" status.
|
||||
(let ((cache-key (page-data-cache-key page-name params))
|
||||
(predicted (optimistic-cache-update cache-key mutator-fn)))
|
||||
;; Re-render with predicted data immediately
|
||||
(when predicted
|
||||
(try-rerender-page page-name params predicted))
|
||||
;; Send to server
|
||||
(execute-action action-name payload
|
||||
(fn (result)
|
||||
;; Success: update cache with server truth, confirm
|
||||
(when result
|
||||
(page-data-cache-set cache-key result))
|
||||
(optimistic-cache-confirm cache-key)
|
||||
(when result
|
||||
(try-rerender-page page-name params result))
|
||||
(log-info (str "sx:optimistic confirmed " page-name))
|
||||
(when on-complete (on-complete "confirmed")))
|
||||
(fn (error)
|
||||
;; Failure: revert to snapshot
|
||||
(let ((reverted (optimistic-cache-revert cache-key)))
|
||||
(when reverted
|
||||
(try-rerender-page page-name params reverted))
|
||||
(log-warn (str "sx:optimistic reverted " page-name ": " error))
|
||||
(when on-complete (on-complete "reverted"))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Offline data layer (Phase 7d)
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Connectivity tracking + offline mutation queue.
|
||||
;; When offline, mutations are queued locally. On reconnect, queued mutations
|
||||
;; are replayed in order via submit-mutation.
|
||||
|
||||
(define _is-online true)
|
||||
(define _offline-queue (list))
|
||||
|
||||
(define offline-is-online?
|
||||
(fn () _is-online))
|
||||
|
||||
(define offline-set-online!
|
||||
(fn (val)
|
||||
(set! _is-online val)))
|
||||
|
||||
(define offline-queue-mutation
|
||||
(fn (action-name payload page-name params mutator-fn)
|
||||
;; Queue a mutation for later sync. Apply optimistic update locally.
|
||||
(let ((cache-key (page-data-cache-key page-name params))
|
||||
(entry (dict
|
||||
"action" action-name
|
||||
"payload" payload
|
||||
"page" page-name
|
||||
"params" params
|
||||
"timestamp" (now-ms)
|
||||
"status" "pending")))
|
||||
(append! _offline-queue entry)
|
||||
;; Apply optimistic locally (reuses Phase 7c)
|
||||
(let ((predicted (optimistic-cache-update cache-key mutator-fn)))
|
||||
(when predicted
|
||||
(try-rerender-page page-name params predicted)))
|
||||
(log-info (str "sx:offline queued " action-name " (" (len _offline-queue) " pending)"))
|
||||
entry)))
|
||||
|
||||
(define offline-sync
|
||||
(fn ()
|
||||
;; Replay all pending mutations. Called on reconnect.
|
||||
(let ((pending (filter (fn (e) (= (get e "status") "pending")) _offline-queue)))
|
||||
(when (not (empty? pending))
|
||||
(log-info (str "sx:offline syncing " (len pending) " mutations"))
|
||||
(for-each
|
||||
(fn (entry)
|
||||
(execute-action (get entry "action") (get entry "payload")
|
||||
(fn (result)
|
||||
(dict-set! entry "status" "synced")
|
||||
(log-info (str "sx:offline synced " (get entry "action"))))
|
||||
(fn (error)
|
||||
(dict-set! entry "status" "failed")
|
||||
(log-warn (str "sx:offline sync failed " (get entry "action") ": " error)))))
|
||||
pending)))))
|
||||
|
||||
(define offline-pending-count
|
||||
(fn ()
|
||||
(len (filter (fn (e) (= (get e "status") "pending")) _offline-queue))))
|
||||
|
||||
(define offline-aware-mutation
|
||||
(fn (page-name params action-name payload mutator-fn on-complete)
|
||||
;; Top-level mutation function. Routes to submit-mutation when online,
|
||||
;; offline-queue-mutation when offline.
|
||||
(if _is-online
|
||||
(submit-mutation page-name params action-name payload mutator-fn on-complete)
|
||||
(do
|
||||
(offline-queue-mutation action-name payload page-name params mutator-fn)
|
||||
(when on-complete (on-complete "queued"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -1061,6 +1194,10 @@
|
||||
;; (resolve-page-data name params cb) → void; resolves data for a named page.
|
||||
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
|
||||
;; when data is available. params is a dict of URL/route parameters.
|
||||
;; (execute-action name payload on-success on-error) → void; POST to server,
|
||||
;; calls (on-success data-dict) or (on-error message).
|
||||
;; (try-rerender-page page-name params data) → void; re-evaluate and swap
|
||||
;; the current page content with updated data bindings.
|
||||
;;
|
||||
;; From boot.sx:
|
||||
;; _page-routes → list of route entries
|
||||
@@ -1080,4 +1217,8 @@
|
||||
;; (csrf-token) → string
|
||||
;; (cross-origin? url) → boolean
|
||||
;; (now-ms) → timestamp ms
|
||||
;;
|
||||
;; === Offline persistence ===
|
||||
;; (persist-offline-data key data) → void; write to IndexedDB
|
||||
;; (retrieve-offline-data key cb) → void; read from IndexedDB, calls (cb data)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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