# React bindings

Thin React layer over `@ossy/sdk`. One hook — **`useSdk()`** — for reads, writes, cache invalidation, and optimistic updates.

## Getting started

```bash
npm install @ossy/sdk-react @ossy/sdk @ossy/fold
```

```jsx
import { WorkspaceProvider, useSdk, AsyncStatus } from '@ossy/sdk-react'
import { ReactSdk } from '@ossy/sdk-react'
import { ListResources, CreateResource } from '@ossy/resources'

const sdk = ReactSdk.of({
  workspaceId: 'your-workspace-id',
  apiUrl: 'https://api.ossy.se/api/v0',
})

export const App = () => (
  <WorkspaceProvider sdk={sdk}>
    <MyComponent />
  </WorkspaceProvider>
)

function MyComponent() {
  const sdk = useSdk()
  const location = '/@ossy/domains/'
  const { status, data: resources, error, refetch } = sdk.read(ListResources, { location })

  const handleCreate = async () => {
    await sdk.invoke(CreateResource, { type: 'document', location, name: 'Hello', content: {} })
    sdk.invalidate(sdk.cacheKey(ListResources, { location }))
  }

  if (status === AsyncStatus.Loading) return <>Loading…</>

  return (
    <>
      <button onClick={handleCreate}>Create</button>
      {resources?.map((r) => <div key={r.id}>{r.name}</div>)}
    </>
  )
}
```

`WorkspaceProvider` provides the SDK and shared read cache. **SSE push invalidation is off by default** — enable it only where you need live cross-tab cache updates.

## API

| Export | Purpose |
|---|---|
| `WorkspaceProvider` | Provides SDK + shared read cache (optional app-wide push via `enablePushInvalidation`) |
| `PushInvalidationSubscriber` | Opt-in SSE invalidation for a page or subtree |
| `useSdk()` | Returns `{ invoke, invokeOptimistic, read, invalidate, cacheKey, sdk }` |
| `useRead()` | Same as `sdk.read` — hook for reactive reads |
| `usePushInvalidation()` | Low-level hook; prefer `PushInvalidationSubscriber` |
| `cacheKey(action, payload)` | Stable cache key for an action + payload |
| `projectionKey(projectionId, scopeId)` | Cache key for projection reads |
| `applyOptimisticResource()` | Fold a pending event into cached resource state |
| `rollbackOptimisticResource()` | Restore cache entry after failed optimistic invoke |
| `useReadCacheStore()` | Access the shared read-cache store inside `<Cache>` |
| `ReactSdk` | Browser SDK with `invoke` and `subscribePush` |
| `ActionRef` | Platform action POJO type `{ id, access? }` |
| `resolveActionId()` | Normalize dot ids to slash (`@ossy.booking.actions.create` → `@ossy/booking/actions/create`) |
| `AsyncStatus` | Loading state constants for UI |
| `normalizeLocation()` | Normalize resource location paths |
| `stableSerialize()` | Deterministic JSON for cache keys |

## `useSdk()` shape

```ts
const sdk = useSdk()

// Reactive read — re-renders when cache updates
const { status, data, error, refetch } = sdk.read(ListResources, { location })

// Command
await sdk.invoke(CreateResource, payload)

// Optimistic command — folds pending event into cached resource, rolls back on error
await sdk.invokeOptimistic(UpdateResource, payload, {
  type: '@ossy/booking/schema/booking',
  resourceId: 'booking-1',
  event: 'Updated',
  payload: { status: 'confirmed' },
})

// Invalidate cache (triggers re-fetch on next read)
sdk.invalidate('location:/@ossy/domains/')
sdk.invalidate(sdk.cacheKey(ListResources, { location }))
```

`read` is a React hook — call it unconditionally at the top of your component (same rules as `useState`).

## Push invalidation

Push invalidation (ADR 0008 §9) opens a long-lived SSE connection to `GET /events` per subscriber. **It is disabled by default** because most pages only need fresh data after their own writes (`sdk.invalidate()` / refetch).

### When to enable

Use push only on pages or components that benefit from **background or cross-tab** updates, for example:

- Collaborative resource lists that should refresh when another user edits
- Long-lived dashboards or inbox views
- Dev tooling that watches task runs or automation

Avoid enabling it on static marketing pages, auth flows, or one-shot forms.

### Page-level (recommended)

```jsx
import { PushInvalidationSubscriber } from '@ossy/sdk-react'

export default function ResourcesPage() {
  return (
    <>
      <PushInvalidationSubscriber />
      {/* … */}
    </>
  )
}
```

Requires `workspaceId` on the SDK (or same-origin workspace cookie). Must render inside `WorkspaceProvider`.

### App-wide (optional)

In `src/config.js`:

```js
export default {
  enablePushInvalidation: true,
}
```

Or pass `enablePushInvalidation` to `WorkspaceProvider` in custom setups.

### Manual wiring

```tsx
import { usePushInvalidation, useReadCacheStore } from '@ossy/sdk-react'

function MyBridge({ sdk }) {
  const store = useReadCacheStore()
  usePushInvalidation(sdk, store)
  return null
}
```

See [@ossy/sdk README](../sdk/README.md#push-invalidation-adr-0008) for server-side details.

## Cache key conventions

| Action + payload | Cache key |
|---|---|
| `@ossy/resources/actions/list` + `{ location }` | `location:${normalizeLocation(location)}` |
| `@ossy/resources/actions/search` + query | `search:${stableSerialize(query)}` |
| `@ossy/resources/actions/get` + `{ resourceId \| id }` | `resource:${resourceId}` |
| `@ossy/booking/actions/list` | `action:@ossy/booking/actions/list` |
| `@ossy/platform/actions/list-task-runs` + `{ workspaceId }` | `projection:@ossy/platform/data/task-run-list:${workspaceId}` |
| default | `action:${actionId}` or `action:${actionId}:${stableSerialize(payload)}` |

Import action POJOs from feature packages (`@ossy/resources`, `@ossy/workspaces`, …). Action ids use canonical `@ossy/{package}/actions/...` notation.

## Dependencies

Peer dependencies: `@ossy/sdk`, `@ossy/fold`, `react`, `react-dom`.

No Ramda.
