# Managers and Middleware

Reactive Data Client uses the [flux store](https://facebookarchive.github.io/flux/docs/in-depth-overview/) pattern, which is
characterized by an easy to [understand and debug](https://dataclient.io/vue/getting-started/debugging.md) the store's [undirectional data flow](https://en.wikipedia.org/wiki/Unidirectional_Data_Flow_\(computer_science\)). State updates are performed by a [reducer function](https://github.com/reactive/data-client/blob/master/packages/core/src/state/reducer/createReducer.ts#L19).

In flux architectures, it is critical all functions in the flux loop are [pure](https://en.wikipedia.org/wiki/Pure_function).
Managers provide centralized orchestration of side effects. In other words, they are the means to interface
with the world outside Data Client.

For instance, [NetworkManager](https://dataclient.io/vue/api/NetworkManager.md) orchestrates data fetching and [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager.md)
keeps track of which resources are subscribed with [useLive](https://dataclient.io/vue/api/useLive.md) or [useSubscription](https://dataclient.io/vue/api/useSubscription.md). By centralizing control, [NetworkManager](https://dataclient.io/vue/api/NetworkManager.md) automatically deduplicates fetches, and [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager.md)
will keep only actively rendered resources updated.

This makes [Managers](https://dataclient.io/vue/api/Manager.md) the best way to integrate additional side-effects like
[logging](#middleware-logging), [error reporting](#error-reporting), [metrics](#metrics),
[notifications](#notifications), [data streams](#data-stream), [refreshing on focus or reconnect](#refresh-on-focus),
[cross-tab synchronization](#cross-tab-sync), and [offline persistence](#persistence).
They can also be customized to change core behaviors.

| Default managers                                                            |                                                                                                             |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| [NetworkManager](https://dataclient.io/vue/api/NetworkManager.md)           | Turns fetch dispatches into network calls                                                                   |
| [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager.md) | Handles polling [subscriptions](https://dataclient.io/vue/getting-started/data-dependency.md#subscriptions) |
| [DevToolsManager](https://dataclient.io/vue/api/DevToolsManager.md)         | Enables [debugging](https://dataclient.io/vue/getting-started/debugging.md)                                 |
| Extra managers                                                              |                                                                                                             |
| [LogoutManager](https://dataclient.io/vue/api/LogoutManager.md)             | Handles HTTP `401` (or other logout conditions)                                                             |

## Examples

Reactive Data Client improves type-safety and ergonomics by performing dispatches and store access with
its [Controller](https://dataclient.io/vue/api/Controller.md)

### Middleware logging

```typescript
import type { Manager, Middleware } from '@data-client/vue';

export default class LoggingManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    console.log('before', action, controller.getState());
    await next(action);
    console.log('after', action, controller.getState());
  };

  cleanup() {}
}
```

### Error reporting {#error-reporting}

Report failed fetches to monitoring services like [Sentry](https://sentry.io) by inspecting
[SET\_RESPONSE](https://dataclient.io/vue/api/Actions.md#set_response) actions with `error` set.

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/vue';
import { captureException } from '@sentry/vue';

export default class ErrorReportManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    if (action.type === actionTypes.SET_RESPONSE && action.error)
      captureException(action.response, {
        extra: { endpoint: action.endpoint.name, args: action.args },
      });
    return next(action);
  };

  cleanup() {}
}
```

### Metrics {#metrics}

Track fetch timing by observing [FETCH](https://dataclient.io/vue/api/Actions.md#fetch) actions. `action.meta.promise`
resolves when the fetch completes.

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/vue';
import { trackTiming } from './analytics';

export default class MetricsManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    if (action.type === actionTypes.FETCH) {
      const start = performance.now();
      action.meta.promise
        .finally(() => {
          trackTiming(action.endpoint.name, performance.now() - start);
        })
        // the fetch's caller handles errors; this only observes timing
        .catch(() => {});
    }
    return next(action);
  };

  cleanup() {}
}
```

### Notifications (toasts) {#notifications}

Show a toast when any [mutation](https://dataclient.io/rest/guides/side-effects.md) succeeds or fails.

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/vue';
import { toast } from './toast';

export default class ToastManager implements Manager {
  middleware: Middleware = controller => next => async action => {
    if (
      action.type === actionTypes.SET_RESPONSE &&
      action.endpoint.sideEffect
    ) {
      if (action.error) toast.error(`${action.endpoint.name} failed`);
      else toast.success(`${action.endpoint.name} succeeded`);
    }
    return next(action);
  };

  cleanup() {}
}
```

### Refresh on focus or reconnect {#refresh-on-focus}

[Controller.expireAll()](https://dataclient.io/vue/api/Controller.md#expireAll) marks data as [Stale](https://dataclient.io/vue/concepts/expiry-policy.md#stale),
triggering refetch of any _actively rendered_ data without suspending ([stale-while-revalidate](https://dataclient.io/vue/concepts/expiry-policy.md)).
[init()](https://dataclient.io/vue/api/Manager.md#init) and [cleanup()](https://dataclient.io/vue/api/Manager.md#cleanup) manage the event listeners.

```typescript
import type { Manager, Middleware, Controller } from '@data-client/vue';

export default class RefreshManager implements Manager {
  declare protected controller: Controller;
  protected handle = () =>
    this.controller.expireAll({ testKey: () => true });

  middleware: Middleware = controller => {
    this.controller = controller;
    return next => async action => next(action);
  };

  init() {
    window.addEventListener('focus', this.handle);
    window.addEventListener('online', this.handle);
  }

  cleanup() {
    window.removeEventListener('focus', this.handle);
    window.removeEventListener('online', this.handle);
  }
}
```

### Cross-tab synchronization {#cross-tab-sync}

When a mutation succeeds in one tab, mark data stale in all other tabs using
[BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel).

```typescript
import {
  type Manager,
  type Middleware,
  actionTypes,
} from '@data-client/vue';

export default class TabSyncManager implements Manager {
  protected channel = new BroadcastChannel('data-client');

  middleware: Middleware = controller => {
    this.channel.onmessage = () =>
      controller.expireAll({ testKey: () => true });
    return next => async action => {
      if (
        action.type === actionTypes.SET_RESPONSE &&
        action.endpoint.sideEffect &&
        !action.error
      )
        this.channel.postMessage('mutation');
      return next(action);
    };
  };

  cleanup() {
    this.channel.close();
  }
}
```

### Offline persistence {#persistence}

Persist the store with [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
(here via [idb-keyval](https://www.npmjs.com/package/idb-keyval)); restore it with
[DataClientPlugin's `initialState` option](https://dataclient.io/vue/api/DataClientPlugin.md#initialState). IndexedDB writes are
asynchronous and use [structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
instead of blocking the main thread with JSON serialization like `localStorage` would.
Debouncing writes keeps rapid action bursts cheap. Consider [expiry times](https://dataclient.io/vue/concepts/expiry-policy.md)
when restoring.

```typescript
import type { Manager, Middleware } from '@data-client/vue';
import { set } from 'idb-keyval';

export default class PersistManager implements Manager {
  declare protected timer?: ReturnType<typeof setTimeout>;

  middleware: Middleware = controller => next => async action => {
    await next(action);
    // debounce: persist at most once per second
    clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      // in-flight optimistic updates reference functions, so are not persistable
      const state = { ...controller.getState(), optimistic: [] };
      set('data-client', state);
    }, 1000);
  };

  cleanup() {
    clearTimeout(this.timer);
  }
}
```

```ts title="main.ts"
import { createApp } from 'vue';
import { DataClientPlugin, getDefaultManagers } from '@data-client/vue';
import { get } from 'idb-keyval';
import App from './App.vue';
import PersistManager from './PersistManager';

const managers = [...getDefaultManagers(), new PersistManager()];
const initialState = await get('data-client');

const app = createApp(App);
app.use(DataClientPlugin, { initialState, managers });
app.mount('#app');
```

### Middleware data stream (push-based) {#data-stream}

Adding a manager to process data pushed from the server by [websockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
or [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) ensures
we can maintain fresh data when the data updates are independent of user action. For example, a trading app's
price, or a real-time collaborative editor.

```typescript
import type {
  Manager,
  Middleware,
  Controller,
  EntityInterface,
} from '@data-client/vue';

export default class StreamManager implements Manager {
  declare protected controller: Controller;
  declare protected evtSource: WebSocket | EventSource;
  declare protected createEventSource: () => WebSocket | EventSource;
  declare protected entities: Record<string, EntityInterface>;

  constructor(
    createEventSource: () => WebSocket | EventSource,
    entities: Record<string, EntityInterface>,
  ) {
    this.createEventSource = createEventSource;
    this.entities = entities;
  }

  middleware: Middleware = controller => {
    this.controller = controller;
    return next => async action => next(action);
  };

  connect() {
    this.evtSource = this.createEventSource();
    this.evtSource.onmessage = (event: MessageEvent) => {
      try {
        const msg: { type: string; args: [any]; data: any } = JSON.parse(
          event.data,
        );
        if (msg.type in this.entities)
          this.controller.set(
            this.entities[msg.type],
            ...msg.args,
            msg.data,
          );
      } catch (e) {
        console.error('Failed to handle message');
        console.error(e);
      }
    };
  }

  init() {
    this.connect();
  }

  cleanup() {
    this.evtSource?.close();
  }
}
```

[Controller.set()](https://dataclient.io/vue/api/Controller.md#set) allows directly updating [Querable Schemas](https://dataclient.io/rest/api/schema.md#queryable)
directly with `event.data`.

#### Batching high-frequency updates {#batching}

Streams like exchange tickers can send hundreds of messages per second, and connections often start with a large snapshot.
Rather than calling `set()` per message, buffer them and write each batch with an [Array](https://dataclient.io/rest/api/Array.md) schema.
[Controller.set(\[Entity\], rows)](https://dataclient.io/vue/api/Controller.md#set-array) normalizes every row in one store update.

```typescript
export default class StreamManager implements Manager {
  // ...
  protected buffer: Record<string, any[]> = {};
  declare protected flushTimeout?: ReturnType<typeof setTimeout>;

  connect() {
    this.evtSource = this.createEventSource();
    this.evtSource.onmessage = event => {
      const msg = JSON.parse(event.data);
      if (msg.type in this.entities) {
        (this.buffer[msg.type] ??= []).push(msg.data);
        this.flushTimeout ??= setTimeout(this.flush, 50);
      }
    };
  }

  flush = () => {
    const buffer = this.buffer;
    this.buffer = {};
    this.flushTimeout = undefined;
    for (const type in buffer) {
      this.controller.set([this.entities[type]], buffer[type]);
    }
  };

  cleanup() {
    this.evtSource?.close();
    clearTimeout(this.flushTimeout);
    this.flushTimeout = undefined;
    this.buffer = {};
  }
}
```

Rows in one batch that share a pk merge in order and skip [Entity.shouldReorder()](https://dataclient.io/rest/api/Entity.md#shouldreorder),
so buffer only the latest message per pk when order matters.

#### Skipping DevTools for high-frequency updates

When using WebSockets or other real-time data sources, you may want to skip logging
certain high-frequency actions to [DevToolsManager](https://dataclient.io/vue/api/DevToolsManager.md) to avoid
overwhelming the browser extension.

```typescript
import { getDefaultManagers, actionTypes } from '@data-client/vue';
import StreamManager from './StreamManager';
import { Ticker } from './Ticker';

export default function getManagers() {
  return [
    new StreamManager(() => new WebSocket('wss://ws-feed.example.com'), {
      ticker: Ticker,
    }),
    ...getDefaultManagers({
      devToolsManager: {
        // Increase latency buffer for high-frequency updates
        latency: 1000,
        // Skip WebSocket SET actions to avoid log spam
        // (batched writes use the [Ticker] schema)
        predicate: (state, action) =>
          action.type !== actionTypes.SET ||
          (action.schema !== Ticker && action.schema[0] !== Ticker),
      },
    }),
  ];
}
```
