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 orderPatch 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
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
| When | Patches |
|---|---|
The array is bound, or assigned a new array | reset |
A source finishes loading a range | reset |
A gesture, createPanel.create(), eventPanel.update(), a plugin commit | upsert, or remove for a delete, at once (optimistic) |
onMutate throws | upsert of the previous version, or remove of a created event |
onMutate returns a version that differs, keep-server | upsert of the server's version |
onMutate returns a different id | remove of the temporary id, then upsert with the new one |
onMutate resolves and the store already matches | nothing |
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
| middleware | onMutate | |
|---|---|---|
Sees | Every patch: loads, gestures, rollbacks, server versions | User changes only, one mutation each |
Can | Log, mirror, reshape or drop a patch | Save, refuse by throwing, return the server version |
Runs | Synchronously, before the store changes | After the optimistic patch, may be async |
Registered | Once, when the scheduler is created | A prop, read on every call |
Use it for | Audit trails, syncing to another store, defaults on load | Persistence and validation |
Props
| Prop | Type | Default |
|---|---|---|
middleware | StoreMiddleware<T>[] | [] |
onMutate | (m: Mutation<T>) => MutationResult<T> | Promise<...> | - |
events | EventInput<T>[] | - |
source | EventSourceFn<T> | - |