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:
| Endpoint | Purpose |
|---|---|
GET /api/parked-sales | List active, newest first |
POST /api/parked-sales | Create from a Sales-Till park |
POST /api/parked-sales/upsert-by-ticket | Service-ticket dedupe path — INSERTs or UPDATEs the row keyed by ticket_id |
PUT /api/parked-sales/:id | Update lines / totals (used for the v0.6.150 "add funds to layaway" path) |
DELETE /api/parked-sales/:id | Soft-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:
apiTicketCreateapiTicketUpdateapiTicketStatusTransitionapiTicketLineCreate(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:
- 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 byticket_id) intoparked_sales+ broadcaststopic=parked_sales. - Every connected WebSocket receives the invalidation → terminal B's client calls
helmInvalidate('parked_sales')→ forward bridge dispatcheshelm:parked-sales-changed→syncParkedSalesFromServer→ server returns the new row → local cache updated → Sales ledger re-render picks up the new row. - 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
| Topic | Triggered by | Legacy event the forward bridge dispatches |
|---|---|---|
parked_sales | every parked-sale endpoint mutation | helm:parked-sales-changed |
service_tickets | every ticket mutation | helm:service-ledger-refresh |
inventory | order-queue mutations + part-line creates | helm: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 —
_suppressNextSyncflag 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
- Data-consistency layers — Layer 2 WebSocket / Durable Object + Layer 3 cart holds + Layer 4 reactive query framework
- Slice 5 — Transactions & Payments — the parked-sale ledger, resume-source banner, layaway / special-order flow
- Slice 4 — Service Tickets — the Completed → auto-park-Service-row trigger that uses these endpoints
- c4 — Container — migration list including 062