Files
inter-hub/docs/ihp-realtime.md
tegwick 8b6ce5bbc8 docs: add specification, reference docs, workplan, and agent guidance
Adds all Phase 0 content that was created but never committed:
- CLAUDE.md and SCOPE.md — agent and developer orientation
- specs/TailwindForInteractionHubs_v0.2.md — IHF Tailwind coding guide
- docs/ — five IHP v1.5 reference guides (overview, data, controllers, realtime, ihf-mapping)
- workplans/IHUB-WP-0001 — Phase 1 implementation plan (12 tasks, state-hub synced)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 02:07:13 +01:00

9.0 KiB

IHP: Realtime Capabilities

IHP offers four distinct realtime mechanisms. Choosing the right one for a given IHF surface is important — they have very different trade-offs.


Summary Comparison

Mechanism Best For Client Requirement State Lives
AutoRefresh Read-heavy live dashboards Vanilla JS (morphdom) Server / DB
DataSync Reactive JS components embedded in server pages JS SDK + bundler DB (via RLS)
Server-Side Components Rich interactive UI with Haskell state machine Vanilla JS Server (Haskell)
HTMX Partial page updates triggered by user actions htmx.js Server

1. AutoRefresh

Enables server-side views to automatically re-render when the underlying database changes. No client-side framework or state management required.

How It Works

  1. Wrap an action with autoRefresh do ...
  2. IHP tracks which DB tables the action's queries touched (via PostgreSQL LISTEN/NOTIFY)
  3. A WebSocket connection is established when the page loads
  4. On any INSERT/UPDATE/DELETE to tracked tables, the server re-runs the action
  5. If the generated HTML differs, the delta is sent over WebSocket; morphdom applies it to the DOM with minimal DOM mutations
action HubDashboardAction { hubId } = autoRefresh do
    hub     <- fetch hubId
    widgets <- query @Widget |> filterWhere (#hubId, hubId) |> fetch
    recentEvents <- query @InteractionEvent
        |> filterWhere (#hubId, hubId)
        |> orderByDesc #occurredAt
        |> limit 20
        |> fetch
    render HubDashboardView { .. }

Layout Requirements

defaultLayout inner = [hsx|
    <head>
        {autoRefreshMeta}
        <script src="/vendor/morphdom.js"></script>
        <script src="/vendor/ihp-auto-refresh.js"></script>
    </head>
    ...
|]

Custom SQL Tracking

For sqlQuery calls that bypass the query builder, manually register table reads:

trackTableRead "interaction_events"

IHF Use Cases

  • Hub operator dashboard — live widget counts, recent interaction signals, annotation feed
  • Governance ledger view — live requirement candidate list
  • Triage board — live annotation queue

AutoRefresh is the right choice for all of these: server-rendered, no JS complexity, updates arrive automatically.


2. IHP DataSync

A WebSocket-based API that allows JavaScript to query the database directly, mirroring the Haskell query builder in JS. For reactive JS components embedded in otherwise server-rendered pages.

JavaScript API

// One-time fetch
const annotations = await query('annotations')
    .where('widget_id', widgetId)
    .orderBy('created_at', 'DESC')
    .fetch();

// Realtime subscription (React hook)
const { records: annotations } = useQuery(
    query('annotations')
        .where('widget_id', widgetId)
        .orderBy('created_at', 'DESC')
);

// Mutations
await createRecord('annotations', { widgetId, body, category });
await updateRecord('annotations', id, { body });
await deleteRecord('annotations', id);

When useQuery() establishes a subscription, all connected clients automatically re-render when the query result changes.

Security: Row Level Security

DataSync relies on PostgreSQL Row Level Security (RLS). IHP runs with two DB roles:

  • Privileged owner role — used by Haskell server code
  • ihp_authenticated — used by DataSync (what JS clients see)

Example RLS policy:

-- Users can only see their own annotations
CREATE POLICY "Users can view own annotations" ON annotations
    FOR SELECT
    USING (actor_id = ihp_user_id());

-- Anyone can view annotations on public widgets
CREATE POLICY "Public widget annotations are readable" ON annotations
    FOR SELECT
    USING (EXISTS (
        SELECT 1 FROM widgets w
        WHERE w.id = annotations.widget_id
        AND w.policy_scope = 'public'
    ));

Setup Requirements

Node.js/npm, a frontend bundler (esbuild recommended), DataSync JS SDK, WebSocket + REST API controllers enabled in FrontController.

IHF Use Cases

  • Annotation composer — reactive annotation entry form with live thread updates
  • Widget feedback inbox — live feed of all annotations on a specific widget for the widget owner
  • Cross-tenant widget embeds — widget UIs embedded in third-party pages; RLS isolates each widget's data

3. Server-Side Components

React-like components that run entirely on the server. Rich interactive UI with a Haskell state machine — no JS state management needed.

Structure

A component has:

  • State — a Haskell data type
  • Actions — a sum type
  • Render — HSX function from state to HTML
  • componentDidMount — hook for async data loading on connect
-- Web/Component/AnnotationComposer.hs

data AnnotationComposer = AnnotationComposer
    { widgetId  :: !(Id Widget)
    , body      :: !Text
    , category  :: !Text
    , submitted :: !Bool
    }

data AnnotationComposerController
    = UpdateBodyAction  { body :: !Text }
    | UpdateCategoryAction { category :: !Text }
    | SubmitAnnotationAction
    deriving (Eq, Show, Data)

instance Component AnnotationComposer AnnotationComposerController where
    initialState = AnnotationComposer
        { widgetId  = error "set by mount"
        , body      = ""
        , category  = "friction"
        , submitted = False
        }

    action state UpdateBodyAction { body } = pure state { body }
    action state UpdateCategoryAction { category } = pure state { category }
    action state SubmitAnnotationAction = do
        createRecord (newRecord @Annotation
            |> set #widgetId state.widgetId
            |> set #body state.body
            |> set #category state.category)
        pure state { submitted = True, body = "" }

    render state = [hsx|
        <div class="annotation-composer">
            <textarea
                value={state.body}
                onInput={callServerAction' UpdateBodyAction { body = inputValue }}
            />
            <select onInput={callServerAction' UpdateCategoryAction { category = selectValue }}>
                <option value="friction">Friction</option>
                <option value="wish">Wish</option>
                <option value="defect">Defect</option>
            </select>
            <button onclick={callServerAction SubmitAnnotationAction}>Submit</button>
        </div>
    |]

Lifecycle

  1. Client triggers action: callServerAction('SubmitAnnotationAction')
  2. Server evaluates action handler → produces new state
  3. Server re-renders → diffs HTML → sends granular DOM patch over WebSocket
  4. morphdom applies patch (preserves third-party JS library DOM state)

Registration

-- Web/FrontController.hs
routeComponent @AnnotationComposer
-- In a view:
{component @AnnotationComposer}

Current Status

Described as "early development stage — expect bugs and API changes." Suitable for experimental use but not for core production paths yet.

IHF Use Cases

  • Annotation entry UI with live category validation and preview
  • Widget triage panel with drag-to-sort and inline editing
  • Governance decision form with multi-step workflow state

4. HTMX Integration

IHP pairs cleanly with htmx (both are hypermedia-first). Use respondHtml (partial renders without layout) in controller actions:

action AppendAnnotationAction { widgetId } = do
    let annotation = newRecord @Annotation
    annotation
        |> fill @'["body", "category"]
        |> validateField #body nonEmpty
        |> ifValid \case
            Left _ -> respondHtml [hsx|<div class="error">Body required</div>|]
            Right annotation -> do
                annotation <- createRecord annotation
                respondHtml (renderAnnotation annotation)

Reinitialise htmx after Turbolinks page transitions or AutoRefresh updates:

document.addEventListener('turbolinks:load', () => htmx.process(document.body));

IHF Use Cases

  • Governance ledger append — POST an approval/rejection action → server appends to ledger and returns updated fragment
  • Annotation triage status update — inline status toggle without page reload
  • Requirement candidate promotion — single-button action that updates status and returns the updated card

Decision Guide for IHF Phase 1

Surface Recommended Mechanism Rationale
Hub dashboard (live signal counts, recent events) AutoRefresh Server-rendered, zero JS complexity
Widget detail page (event history) AutoRefresh Same — read-heavy, aggregate view
Annotation feed (live thread) AutoRefresh or DataSync AutoRefresh if all users see the same view; DataSync if per-user filtered
Annotation entry form Server-Side Components (experimental) or HTMX SSC for rich state machine; HTMX for simple append
Widget registration flow Standard IHP forms No realtime needed
Triage board (drag, inline edit) Server-Side Components Rich interaction state