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:
2026-03-08 01:30:12 +00:00
parent 4c1853bc7b
commit 5f20a16aa0
8 changed files with 483 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)
;; --------------------------------------------------------------------------