Merge worktree-zero-tooling-essay into macros

Resolves conflicts by keeping both:
- HEAD: cache invalidation, service worker, sw-post-message
- Worktree: optimistic mutations, offline queue, action endpoint
Plans.sx unified with combined 7c/7d documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 01:33:49 +00:00
8 changed files with 465 additions and 32 deletions

View File

@@ -887,6 +887,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
# Mount service worker at root scope for offline data layer
mount_service_worker(app)
# 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:
@@ -1225,3 +1228,56 @@ def mount_service_worker(app: Any) -> None:
app.add_url_rule("/sx-sw.js", endpoint="sx_service_worker", view_func=serve_sw)
logger.info("Mounted service worker at /sx-sw.js")
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

@@ -663,6 +663,139 @@
(update-page-cache hdr-update data)))))))
;; --------------------------------------------------------------------------
;; 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
;; --------------------------------------------------------------------------
@@ -1137,6 +1270,10 @@
;; when data is available. params is a dict of URL/route parameters.
;; (parse-sx-data text) → parsed SX data value, or nil on error.
;; Used by cache update to parse server-provided data in SX format.
;; (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
@@ -1160,4 +1297,8 @@
;; === Cache management ===
;; (parse-sx-data text) → parsed SX data value, or nil on error
;; (sw-post-message msg) → void; post message to active service worker
;;
;; === Offline persistence ===
;; (persist-offline-data key data) → void; write to IndexedDB
;; (retrieve-offline-data key cb) → void; read from IndexedDB, calls (cb data)
;; --------------------------------------------------------------------------