generated from coulomb/repo-seed
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>
267 lines
9.0 KiB
Markdown
267 lines
9.0 KiB
Markdown
# 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
|
|
|
|
```haskell
|
|
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
|
|
|
|
```haskell
|
|
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:
|
|
```haskell
|
|
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
|
|
|
|
```javascript
|
|
// 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:
|
|
```sql
|
|
-- 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
|
|
|
|
```haskell
|
|
-- 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
|
|
|
|
```haskell
|
|
-- Web/FrontController.hs
|
|
routeComponent @AnnotationComposer
|
|
```
|
|
|
|
```haskell
|
|
-- 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:
|
|
|
|
```haskell
|
|
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:
|
|
```javascript
|
|
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 |
|