Skip to main content

Multi-user realtime

Helm runs on multiple tills in the same shop. A mechanic marks a service ticket Completed in the back; the cashier at the front sees the yellow "Service" parked-sale row appear on the Sales ledger in 100–300 ms. Adding a part to a Slot fires the Bucket reorder check on every other terminal at once. The bucket clears on terminal A → terminal B sees the empty state immediately.

This page documents how that works. The architecture shipped as a three-tier change in v0.6.112 built on the existing Layer 2 WebSocket (Durable Object + broadcast bridge) that's been there since v0.4.238.

The problem v0.6.112 solved

Before v0.6.112, parked sales lived in localStorage.helm_parked_sales_v1 — a per-device cache. The Layer 2 WebSocket carried helm-query topic invalidations, but the Sales ledger / Service ledger / Orders bucket renderers were written with the legacy document.dispatchEvent pattern from the pre-helmQuery era and never wired up to the WebSocket. Result: every terminal had its own private view of "what's parked," and the mechanic-marks-Completed-and-cashier-sees-it flow didn't work cross-terminal — only on the terminal that did the write.

Three-tier solution

Tier 1 — server-side parked_sales (migration 062)

The source of truth moves off localStorage and into D1.

CREATE TABLE parked_sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER, -- nullable, advisory FK (deleted tickets don't break the row)
customer_id INTEGER, -- nullable, advisory FK
source TEXT NOT NULL, -- 'sales_till' | 'service_ticket' | 'estimate' | 'deposit'
till_json TEXT NOT NULL, -- full till snapshot (lines, customer, taxes, source pointers, holds)
parked_at TEXT NOT NULL,
parked_by INTEGER, -- staff id
parked_by_name TEXT,
notes TEXT, -- e.g. 'auto_parked:idle_90s' for v0.6.161 idle parks
is_active INTEGER NOT NULL DEFAULT 1, -- soft-delete flag
deleted_at TEXT,
...
);

CREATE INDEX idx_parked_sales_active ON parked_sales(is_active, parked_at DESC);
CREATE INDEX idx_parked_sales_by_ticket ON parked_sales(ticket_id) WHERE ticket_id IS NOT NULL;
CREATE INDEX idx_parked_sales_by_customer ON parked_sales(customer_id) WHERE customer_id IS NOT NULL;

Foreign keys are deliberately advisory (NULLable, not enforced) so deleted customers or cancelled tickets don't break the parked-sale row that referenced them.

Endpoints:

EndpointPurpose
GET /api/parked-salesList active, newest first
POST /api/parked-salesCreate from a Sales-Till park
POST /api/parked-sales/upsert-by-ticketService-ticket dedupe path — INSERTs or UPDATEs the row keyed by ticket_id
PUT /api/parked-sales/:idUpdate lines / totals (used for the v0.6.150 "add funds to layaway" path)
DELETE /api/parked-sales/:idSoft-delete (consume on Charge, or cancel)

Every mutation calls broadcastTopicInvalidation(env, 'parked_sales') before returning.

Tier 2 — server broadcasts on service-ticket mutations

broadcastTopicInvalidation('service_tickets') now fires from:

  • apiTicketCreate
  • apiTicketUpdate
  • apiTicketStatusTransition
  • apiTicketLineCreate (also broadcasts 'inventory' for part lines so the Bucket reorder check fires cross-terminal)
  • apiTicketDelete

Plus the parked_sales topic fires from every parked-sales endpoint listed above. The inventory topic fires from order-queue mutations.

Tier 3 — forward bridge (helm-query.js client)

This is the change that made the existing renderers work. The Layer 2 WebSocket has shipped clients with onmessage → helmInvalidate(topic) since v0.4.238 — but only helmQuery subscribers reacted. The Sales ledger, Service ledger, Owner kanban, parked-sales mirror on the Sales side, Orders bucket — all use the legacy document.dispatchEvent pattern from before helmQuery.

The forward bridge in helm-query.js:

// helmInvalidate emits a sentinel event that fans out to legacy listeners
window.helmInvalidate = function(topic) {
// ... existing helmQuery invalidation logic
document.dispatchEvent(new CustomEvent('helm:topic-invalidated', { detail: { topic } }));
};

// Forward bridge — maps topic → legacy custom events
document.addEventListener('helm:topic-invalidated', (ev) => {
if (BRIDGE_REENTRY_GUARD) return;
const topic = ev.detail.topic;
BRIDGE_REENTRY_GUARD = true;
try {
if (topic === 'service_tickets') document.dispatchEvent(new Event('helm:service-ledger-refresh'));
if (topic === 'parked_sales') document.dispatchEvent(new Event('helm:parked-sales-changed'));
if (topic === 'inventory') document.dispatchEvent(new Event('helm:bucket-changed'));
} finally {
BRIDGE_REENTRY_GUARD = false;
}
});

The re-entry guard prevents an infinite loop with the existing legacy bridge (which goes the other way: legacy event → topic invalidation, so local writes also broadcast).

Client mirrors (parked sales)

parkCurrentSale, helmCreateParkedSale, helmUpsertServiceParkedSale, helmDeleteParkedSale, consumeTillSource all POST/DELETE the server first, then mirror the result into the local cache on success. readParkedSales / writeParkedSales are unchanged (cache-only) so the synchronous readers in the Sales-ledger render loop keep working without an async rewrite.

syncParkedSalesFromServer() fires on boot and on every helm:parked-sales-changed event (which now also fires from REMOTE broadcasts via the forward bridge). _parkedSyncing and _suppressNextSync flags prevent the local-write-triggers-its-own-sync loop.

Server entry id format: pk_<integer> (string) so existing client code that treats id as opaque keeps working. DELETE strips the pk_ prefix when building the URL.

End-to-end flow

The flow that v0.6.112 unblocked:

  1. Service operator on terminal A marks ticket TKT-1234 Completed → client calls helmUpsertServiceParkedSale(1234) → POST /api/parked-sales/upsert-by-ticket → server INSERTs (or UPDATEs the existing row keyed by ticket_id) into parked_sales + broadcasts topic=parked_sales.
  2. Every connected WebSocket receives the invalidation → terminal B's client calls helmInvalidate('parked_sales') → forward bridge dispatches helm:parked-sales-changedsyncParkedSalesFromServer → server returns the new row → local cache updated → Sales ledger re-render picks up the new row.
  3. Cashier on terminal B sees the yellow "Service" parked-sale row appear on the Sales ledger in real time. Clicks it → standard parked-sale resume → cart loads with the ticket's lines → Charge → consumed.

Latency budget: ~50 ms server write + ~50 ms broadcast fan-out + ~100 ms client sync + ~50 ms render = 200–300 ms terminal-A-to-terminal-B in the typical case. Under network congestion it can stretch to ~1 s.

Topics broadcast today

TopicTriggered byLegacy event the forward bridge dispatches
parked_salesevery parked-sale endpoint mutationhelm:parked-sales-changed
service_ticketsevery ticket mutationhelm:service-ledger-refresh
inventoryorder-queue mutations + part-line createshelm:bucket-changed

Additional topics from the pre-v0.6.112 era (used by helmQuery subscribers directly, no forward-bridge mapping needed): customers, transactions, staff, shop_config, service_menu, bulk_items. These work via the existing helmQuery flow and don't need legacy renderers, so they stay narrowly scoped.

Why a forward bridge instead of rewriting the renderers

The legacy renderers (parkedRow, renderTodayLedger, renderBucket, etc.) predate helmQuery and were written as synchronous-render-from-localStorage. Rewriting them to subscribe via helmQuery would be a multi-day pass with a high regression risk — every code path that calls writeParkedSales(localStorage) would need its corresponding helmQuery.subscribe('parked_sales', ...) set up, render functions would need to become async, and the entire render pipeline would change shape.

The forward bridge is architectural mortar: it lets the renderers stay as-is (synchronous, reading from localStorage) while still receiving cross-terminal invalidations via the legacy event pattern they already understand. Cost: ~30 lines of bridge code + the re-entry guard. Future renderers SHOULD subscribe via helmQuery directly; the bridge is grandfathered support, not the path forward.

Failure modes + safeguards

  • Offline write — POST fails → local cache writes still happen (UI doesn't block) → on reconnect the syncs reconcile (server is the tiebreaker). The Layer 2 idempotency-key wrapper (see data-consistency layers) handles the retry safely.
  • Stale broadcast on a terminal that already mutated locally_suppressNextSync flag set right after a local write swallows the inbound broadcast that THIS terminal triggered. Other terminals' broadcasts still fire normally.
  • Soft-delete race (terminal A consumes the parked sale just as terminal B is about to click it) — terminal B's click hits the server with DELETE /api/parked-sales/:id; server returns 404 (or 410 Gone); client handles gracefully (alert + refresh the ledger).
  • Network down — Layer 1 connection pill in the topnav shows Offline; auto-park behaviour continues locally; sync fires on reconnect.

See also