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)