# Custom spaces

> Implement the Space<Region> contract for your own domain, with laws, worked key-set and numeric-range examples, the optional unionInto, and common pitfalls.

Source: https://dirtytalk.42.dog/docs/engine/custom-spaces/

A `Space<Region>` defines what "changed" and "interested in" mean for your data. The engine ships no concrete space; this guide covers the contract and two worked implementations.

## The contract

```ts
interface Space<Region> {
  empty(): Region;
  isEmpty(r: Region): boolean;
  union(a: Region, b: Region): Region;
  intersects(interest: Region, dirty: Region): boolean;
  unionInto?(acc: Region, b: Region): Region;
}
```

| Method                        | Used for                                                                                                            |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `empty()`                     | The region meaning "nothing".                                                                                       |
| `isEmpty(r)`                  | Tells the channel a flush has nothing to deliver. When the accumulated region is empty, no subscriber is consulted. |
| `union(a, b)`                 | Merges two dirty regions during `mark`.                                                                             |
| `intersects(interest, dirty)` | Delivery predicate: does this subscriber care about this change?                                                    |
| `unionInto(acc, b)`           | Optional in-place `union`. See [below](#unioninto-optional).                                                        |

### Laws

The source documents these:

- `union(empty(), r)` equals `r`.
- `intersects(empty(), _)` returns `false`.
- All operations are pure: the same inputs give the same output, with no side effects and no mutation of arguments. The one exception is `unionInto`, which may mutate its `acc` argument.

The channel also relies on these properties, so implement them too:

- `isEmpty(empty())` is `true`.
- `union` is associative and commutative, up to equivalence. Marks arrive in arbitrary order and grouping, and the result must not depend on it.
- `intersects(i, empty())` is `false`.
- `intersects` is monotonic: if it is true for `dirty`, it stays true for any `union(dirty, x)`. Otherwise coalescing could hide a change that a single mark would have delivered.
- `union` may return one of its inputs by reference. The channel handles this, but you must never mutate an input yourself.

### Interest and dirty are the same type

The interest thunk returns a `Region` and the accumulated dirty value is a `Region`, so one type must express both "what I watch" and "what changed".

## Example: key-set space

Region is a set of string keys. Two sets intersect when they share a key.

```ts
import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

type Keys = ReadonlySet<string>;

const KeySpace: Space<Keys> = {
  empty: () => new Set(),
  isEmpty: (r) => r.size === 0,
  union: (a, b) => new Set([...a, ...b]),
  intersects: (interest, dirty) => {
    for (const key of interest) if (dirty.has(key)) return true;
    return false;
  },
};

const channel = new DirtyChannel(KeySpace, new MicrotaskScheduler());
channel.subscribe(
  () => new Set(['cart']),
  (dirty) => console.log('cart changed', [...dirty]),
);
channel.mark(new Set(['cart', 'user']));
```

Iterate the smaller set in `intersects` for large regions.

## Example: numeric range space

Region is one half-open range `[start, end)`. Union takes the bounding range, which may over-approximate (a range covering the gap between two marks), but never misses a change. Over-approximation costs extra callbacks, not correctness.

```ts
import { DirtyChannel, RAFScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

interface Range {
  readonly start: number;
  readonly end: number;
}

const EMPTY: Range = { start: 0, end: 0 };
const isEmpty = (r: Range): boolean => r.end <= r.start;

const RangeSpace: Space<Range> = {
  empty: () => EMPTY,
  isEmpty,
  union: (a, b) => {
    if (isEmpty(a)) return b;
    if (isEmpty(b)) return a;
    return { start: Math.min(a.start, b.start), end: Math.max(a.end, b.end) };
  },
  intersects: (interest, dirty) =>
    !isEmpty(interest) &&
    !isEmpty(dirty) &&
    interest.start < dirty.end &&
    dirty.start < interest.end,
};

const rows = new DirtyChannel(RangeSpace, new RAFScheduler());
let viewport: Range = { start: 100, end: 140 };

rows.subscribe(
  () => viewport,
  (dirty) => console.log('repaint rows', dirty.start, dirty.end),
);

rows.mark({ start: 120, end: 121 });
rows.mark({ start: 300, end: 301 });
```

The two marks union to `[120, 301)`, which intersects the viewport, so the callback runs once with that bounding range. A mark of `{ start: 500, end: 501 }` alone would not. Because the interest thunk reads `viewport` at flush time, reassigning `viewport` changes what the subscriber watches with no re-subscribe.

The explicit `isEmpty` branches in `union` are what keep `union(empty(), r)` equal to `r`: without them the empty range `[0, 0)` would stretch the result to include 0.

## unionInto (optional)

```ts
unionInto?(acc: Region, b: Region): Region
```

An accumulate-in-place variant of `union`, and the only operation allowed to mutate an argument (`acc`). Implement it when `union` allocates: merging N marks in one window with a copying `union` copies O(N²) elements in total, while an in-place add is O(N).

```ts
import type { Space } from '@dirtytalk/engine';

const MutableKeySpace: Space<Set<string>> = {
  empty: () => new Set(),
  isEmpty: (r) => r.size === 0,
  union: (a, b) => new Set([...a, ...b]),
  unionInto: (acc, b) => {
    for (const key of b) acc.add(key);
    return acc;
  },
  intersects: (interest, dirty) => {
    for (const key of interest) if (dirty.has(key)) return true;
    return false;
  },
};
```

How the channel uses it:

- The first mark of a window is kept by reference, so the caller's region is never mutated.
- On the second mark the channel calls `union`. If the result is a new object (not `a` or `b`), the channel treats it as its own and uses `unionInto` for later marks in that window.
- The accumulator is replaced before subscribers see it, so no subscriber observes a mutation.
- `b` must never be mutated. `unionInto` must return the merged region, which may be `acc` or a different object.
- Without `unionInto`, the channel uses `union` throughout; adding it is purely an optimization and must produce an equivalent result.

## Pitfalls

- **Mutating inputs in `union` or `intersects`.** Regions passed to `mark` can be stored by reference and delivered to subscribers as-is. Mutating them corrupts later flushes.
- **Mutating the `dirty` argument in a callback.** Every subscriber in the flush receives the same object.
- **Non-identity `empty`.** If `union(empty(), r)` changes `r`, the first real mark is distorted. Special-case empty in `union`, as in the range example.
- **`isEmpty` disagreeing with `intersects`.** If `isEmpty(r)` is true but `intersects(i, r)` is true for some `i`, the flush is skipped anyway. Make empty regions intersect nothing.
- **Under-approximating `union`.** A union that drops part of its inputs loses changes silently. Over-approximate instead.
- **Exceptions.** A throwing `intersects` is isolated per subscriber and reported (see [error handling](/docs/engine/dirty-channel/#error-handling)). A throwing `union`, `isEmpty` or `unionInto` is not: it propagates from `mark` or the flush.
- **Expensive `intersects`.** It runs once per subscriber per flush. Keep it proportional to the size of the interest, and cache derived structures in the space, not per call.
