Advanced

Store Middleware

Every change to the event store, from a load or a drag or a rollback, is one patch. A middleware sees each patch before it lands and can log it, mirror it, reshape it or drop it.

Every Patch Passes Through

A middleware is a function that takes next and returns the function the store will call. Middleware run in the order given; the last one calls the store. Three patch types cover everything.

import type { EventPatch, StoreMiddleware } from '@sv5ui/scheduler';

// A middleware wraps the store's write. It receives next and returns the function
// that will be called with every patch; call next(patch) to let it through.
type StoreMiddleware<T> = (next: (patch: EventPatch<T>) => void) => (patch: EventPatch<T>) => void;

// Three patches
type EventPatch<T> =
  | { type: 'upsert'; event: SchedulerEvent<T> }    // add or replace one event
  | { type: 'remove'; eventId: string }
  | { type: 'reset'; events: SchedulerEvent<T>[] };  // replace everything

<Scheduler bind:events middleware={[logger, audit]} />   // runs in this order

Patch Log

The log lists every patch next to what the fake server does. Move an event with the server accepting: one upsert, and nothing more when the save confirms what is already there. Switch to rejecting: the same upsert, then a second one putting the old version back. Let the server assign ids and create an event by dragging across empty slots: a remove of the temporary id, then an upsert with the server's.

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
All day
Lunch break

Patches and server calls, newest first

  • 03:19:06 reset 16 events
const logger: StoreMiddleware = (next) => (patch) => {
  if (patch.type === 'upsert') console.log('upsert', patch.event.id, patch.event.title);
  if (patch.type === 'remove') console.log('remove', patch.eventId);
  if (patch.type === 'reset') console.log('reset', patch.events.length, 'events');
  next(patch);
};

When Patches Happen

What produces a patch

WhenPatches
The array is bound, or assigned a new arrayreset
A source finishes loading a rangereset
A gesture, createPanel.create(), eventPanel.update(), a plugin commitupsert, or remove for a delete, at once (optimistic)
onMutate throwsupsert of the previous version, or remove of a created event
onMutate returns a version that differs, keep-serverupsert of the server's version
onMutate returns a different idremove of the temporary id, then upsert with the new one
onMutate resolves and the store already matchesnothing

Patterns

Three things middleware is good at. An audit trail is the logger above writing somewhere durable. Mirroring keeps another store, or another tab, in step. Defaults shape data on its way in, wherever it comes from.

// Mirror every change into another tab. Patches carry ZonedDateTime values, so
// serialise them before posting and parse them on the other side.
const channel = new BroadcastChannel('calendar');

const broadcast: StoreMiddleware = (next) => (patch) => {
  next(patch);
  if (patch.type === 'reset') return;                      // loads are per tab
  channel.postMessage(JSON.parse(JSON.stringify(patch, (key, value) =>
    value?.toAbsoluteString ? value.toAbsoluteString() : value
  )));
};

// The receiving tab applies it to its own bound array
channel.onmessage = ({ data }) => {
  if (data.type === 'remove') events = events.filter((e) => e.id !== data.eventId);
  if (data.type === 'upsert') events = [...events.filter((e) => e.id !== data.event.id), data.event];
};
// Shape data on its way into the store: every event without a calendar joins
// "work", whether it arrives from the array, a source, or a gesture
const defaultCalendar: StoreMiddleware = (next) => (patch) => {
  const fix = (event: SchedulerEvent) => (event.calendarId ? event : { ...event, calendarId: 'work' });
  if (patch.type === 'upsert') return next({ ...patch, event: fix(patch.event) });
  if (patch.type === 'reset') return next({ ...patch, events: patch.events.map(fix) });
  next(patch);
};

// The store and the bound array hold what the middleware let through. onMutate
// does not: it receives the mutation as the gesture produced it, before any
// middleware ran. To change what is saved, change it in onMutate.

Not calling next drops the patch. That is rarely what you want: an optimistic upsert that is dropped still reaches onMutate and gets saved, and a dropped reset freezes the calendar. Refuse changes in onMutate instead.

// Not calling next drops the patch. Two things to know before doing that:
//   1. dropping an optimistic upsert does not stop onMutate: the pipeline still saves
//      the change, and the store and the server then disagree. Refuse in onMutate instead.
//   2. dropping a reset stops the calendar from ever updating from the array or a source.
const readOnlyPast: StoreMiddleware = (next) => (patch) => {
  if (patch.type === 'upsert' && patch.event.end.compare(now(zone)) < 0) return;   // see 1
  next(patch);
};

Registered Once

middleware is read when the scheduler is created, because the store is. Changing the array afterwards has no effect. To swap middleware at runtime, remount the scheduler.

<!-- middleware is read when the scheduler is created. Changing the array later
     does nothing; to swap it, remount. -->
{#key middlewareVersion}
  <Scheduler bind:events middleware={current} />
{/key}

Middleware or onMutate

Which one

middlewareonMutate
SeesEvery patch: loads, gestures, rollbacks, server versionsUser changes only, one mutation each
CanLog, mirror, reshape or drop a patchSave, refuse by throwing, return the server version
RunsSynchronously, before the store changesAfter the optimistic patch, may be async
RegisteredOnce, when the scheduler is createdA prop, read on every call
Use it forAudit trails, syncing to another store, defaults on loadPersistence and validation

Props

PropTypeDefault
middlewareStoreMiddleware<T>[][]
onMutate(m: Mutation<T>) => MutationResult<T> | Promise<...>-
eventsEventInput<T>[]-
sourceEventSourceFn<T>-