Visitar URL original
perf(realtime): read hooks answer the same object until what they show moved; where-readers hear only their keys; useRecords throttleMs; a per-type store index by sebyx07 · Pull Request #930 · developerz-ai/ultimate · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,31 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

## [Unreleased]

Nothing yet.
### Added

Tier 3 — realtime.

- **`useRecords(type, { throttleMs })` — a busy feed throttled by its reader.** The first change is
told at once, a burst as ONE notification at the end of the period, the last always lands;
`release()` drops what is owed. A live board taking ten records a second is told 21 times in 20 s
instead of 200 (`scripts/bench/record-store-bench.ts`). Absent or `0`: every change, as before; a
non-finite or fractional value is `X_INVARIANT`. `schedule` injects the timer (tests).
[Realtime](https://github.com/developerz-ai/ultimate/wiki/Realtime#one-record-per-typekey).

### Changed

Tier 3 — realtime.

- **A read hook answers the SAME object until what it shows moved.** `useQuery` (live and not),
`useRecord` and both `useRecords` forms hand back the previous answer — and its `data` array,
across a `ready` ⇄ `refreshing` flip — while every row object matches, so a poll or refetch that
found the same rows re-runs nothing derived from it (50 → 0 new arrays over 50 identical
refetches). Code that relied on a fresh array per read (mutating it) must copy it first.
- **A whole-type `useRecords` with `where` is told only of a record it shows or `where` keeps.** On
a run page the viewer's alerts list heard every fleet run (180 of 200 frames, none its own) — now
0; the page's notifications 260 → 80, 23.4 → 9.2 ms.
- **The record store indexes rows by type**: a whole-type read walks its own type's rows, never
every row on the page (5,000 held runs beside 200 events: 184 → 138 ms for 800 reads).

## 29.1.0 - 2026-10-29

Expand Down
43 changes: 43 additions & 0 deletions docs/history/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -1465,3 +1465,46 @@ sync-node roll). Three rules, each pinned by a failing-first test:
tab's chained timer runs about once a minute; measured from the last frame, every late tick read
an answered socket as two windows dead and redialled. `client-heartbeat.test.ts`. Not changed:
the engine's port reaper (`REAP_AFTER_BEATS`) still judges a tab's beats on the tab's timers.

## 2026-10-29 — a read hook answers what moved, once, and only to whom it moved

Measured on bank-integrations' panel (Ultimate 29.1.0), which had worked around all three in app
code — `throttle.ts` (a board redrawn per record, ten a second, on a phone), `stableRuns` and
`sameData` (new arrays of the same rows defeating Solid's `===`).
`bun run scripts/bench/record-store-bench.ts` replays the load; before → after:

| Scenario | Notifications | New `data` arrays | Time |
|---|---|---|---|
| board: 300 runs, board + alerts readers, 200 frames of others' runs | 400 → 200 (alerts 200 → 0) | 400 → 200 | ≈ same (the board's sort is real work) |
| run page: 4 event readers + alerts, 180 fleet frames + 20 own events | 260 → 80 (alerts 180 → 0) | 260 → 80 | 23.4 → 9.2 ms |
| board with `throttleMs: 1000`, 10 frames/s for 20 s | 200 → 21 | 200 → 21 | 29.4 → 7.8 ms |
| run page, 5,000 fleet runs held, 200 own events × 4 readers | 800 (all real) | 800 | 184 → 138 ms (type index) |
| `useQuery` refetch answering the same 50 rows, × 50 | 100 (status flips are real) | 50 → 0 | — |

- **The same answer back** (`record-answer.ts`, `keptAnswer`): `useQuery` (live and not),
`useRecord` and both `useRecords` forms return the previous answer object — or at least its
`data` array, across a `ready` ⇄ `refreshing` flip — while every row object matches. The store
already kept an unmoved row's object (`SyncedLayer.merge`, structural); the hooks threw that away
by wrapping it in a fresh array per read.
- **Told only when it moves THIS reader** (`moves` in `use-record.ts`): a whole-type reader with
`where` skips a changed key it does not show and `where` does not keep now. A key it shows is
always its change (a row leaving the selection). An answer nobody read since the last
notification is always told: there is nothing current to compare against. A `where` that throws
in the listener counts as a move — the read raises it, and a throwing listener would silence the
store's other listeners in `batch`'s `finally`.
- **`throttleMs` is the reader's**, not the store's: the store's notification stays synchronous
(an optimistic write is visible before `push` returns, and every settle rule leans on that). No
clock — the scheduler's timer IS the period: a call with no timer armed tells at once and arms
one; a call inside it is owed; the timer tells what is owed and re-arms, or closes. Validated by
`finiteCount` before the type is held. The period is armed BEFORE readers hear, and a released
notifier arms nothing: a reader releasing the hook while it is told left a timer behind (#930).
- **The synced layer indexes keys by type** (`#byType`): `keyed(type)` and a replay's `ofType` walked
every row of every type with `startsWith`. The type is the one its writer NAMED (`merge`/`restore` take
it), never split off the key: `?query:<name>` holds a colon, so `type:key` splits two ways
(review on #930).
- Not changed, because the measurements did not ask for it: the wire (records frames already carry
only what moved, resume already starts from the last contiguous `seq`, a gap already re-reads
once and holds frames), and the store's own batching (one notification per frame).
- Moved here from `packages/realtime/CLAUDE.md` (its size ceiling): **every `socket.send` reads its
answer** — a dropped subscribe reply marks it desynced; a dropped roster logs
`sync.presence_roster_dropped`; `drain()` returns `DrainedSocket[]` (`notified`).
7 changes: 6 additions & 1 deletion examples/dummy/apps/web/app/feed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,13 @@ export const config = defineRoute({
* aborting on close), opened with `show()` + `showPopover()` instead of `showModal()` and so
* keeping focus, Tab and Escape itself; charged to every `app/` document, as the router is.
* All of the +3,397 B over main's 166,148 B.
* raised 166kb → 166.5kb (29.2.0, realtime read hooks). measured: 170,149 B (2026-10-29;
* `x build --target static`), against main's 169,509 the same way. why: +640 B (page boot +359,
* island +281) — a read hook answers the same object until what it shows moved (`keptAnswer`),
* a `where` reader is told only of keys it shows or keeps, `useRecords`' opt-in `throttleMs`,
* and the store's per-type index; on bank-integrations' run page 260 → 80 notifications.
*/
budget: { js: '166kb' },
budget: { js: '166.5kb' },
/** The badge's count is a read, so it is resolved here — the only place this page fetches. */
load: () => memberQueries.feedActivity({ orgId: useActor().orgId }),
meta: ({ t }) => ({ title: t('app.feed.metaTitle'), robots: { index: false } }),
Expand Down
4 changes: 2 additions & 2 deletions examples/dummy/x.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"manifestVersion": 1,
"buildId": "a26f36462e404c7c",
"buildId": "dd1a60fe581f267d",
"app": {
"name": "@postly/root",
"version": "0.0.1"
Expand Down Expand Up @@ -431,7 +431,7 @@
"revalidateTags": [],
"surface": "app",
"budget": {
"js": "166kb"
"js": "166.5kb"
}
},
{
Expand Down
8 changes: 2 additions & 6 deletions packages/realtime/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
`upgrade()`, as Bun does.
- **A grant expires; a socket does not**: expired grants are re-decided on an interval
(`hub.onActorChange`, `registry.reauthorize`). No `refresh` = close `1008`; one that raises retries.
- **Every `socket.send` reads its answer**: a dropped subscribe reply marks it desynced; a dropped
roster logs `sync.presence_roster_dropped`; `drain()` returns `DrainedSocket[]` (`notified`).
- **Inbound frames run in a lane, never the socket**: `subscribe` is `sub:<sid>` or `topic:<name>`,
everything else unlaned (`frame-lanes.ts`); a lane exists only while work is queued.
- **A dropped `records` frame is counted AND repaired**: per-node `seq`/`epoch` per channel; a refused
Expand Down Expand Up @@ -256,6 +254,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
`useQuery`'s input is read once, at call time.
- **`useRecords(type, selection)` holds the TYPE** (`retainType`): `ready` at once, recomputed per
change to that type, no row of it evicted while held; the last type hold evicts what it spared.
- **Read hooks answer the SAME object until what they show moved** (`keptAnswer`); a `where`
reader hears only keys it shows or keeps (history, 2026-10-29).
- Every subscription handle is `Disposable`; `[Symbol.dispose]` IS the release. Type claims go in
`type-pins.ts`.
- **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time; `#epoch` is
Expand All @@ -282,9 +282,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
per socket; the engine reaps ports on the longer of its own and the node's.
- **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
`BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.

## Rules for code here

- Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`monotonic()` for durations).

## The page boot
Expand Down Expand Up @@ -312,7 +309,6 @@ bun test packages/realtime/src # from the REPO ROOT
bun run typecheck
```

`bunfig.toml`'s preload (matchers) is read from the cwd.

Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
Expand Down
3 changes: 3 additions & 0 deletions packages/realtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const pair = useRecords('posts', [postId]); // those key
const runs = useRecords<Run>('runs', { // EVERY `runs` record on the page
where: (run) => run.state !== 'done',
order: (a, b) => (a.startedAt < b.startedAt ? 1 : -1),
throttleMs: 1_000, // optional: at most once a second
});
const like = useMutation(LIKE_POST); // await like(input); like.pending
const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
Expand Down Expand Up @@ -279,6 +280,8 @@ same type `@ultimat3/ui` renders.
| A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
| Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
| A list that must GROW with the store is `useRecords(type, { where?, order? })` | a `useQuery` list holds its own answer's keys and `useRecords(type, keys)` the keys it was given, so a record a channel's `records` frame adopts reaches neither. The whole-type form is the channel-records counterpart of a live window: `ready` from its first read, every record of the type the store holds (optimistic ones included), default order by key, and none evicted while it is held — a row it alone kept goes when the last reader of the type lets go (#680) |
| An answer is the SAME object until what it shows moved | `useQuery`, `useRecord` and both `useRecords` forms hand back the previous answer — and its `data` array — while the status and every row object match: a refetch or poll that found the same rows, or a re-read after an unrelated change, re-runs nothing derived from it (Solid's `===`). A whole-type reader with `where` is not even TOLD of a record it neither shows nor would show: on a run page the viewer's alerts list stopped hearing the fleet's runs (180 of 200 frames, 0 its own) |
| A busy feed is throttled by the reader, `useRecords(type, { throttleMs })` | the first change is told at once, a burst as ONE notification at the end of the period, the last always lands; `release()` drops what is owed. A live board on a phone took ten records a second, each a whole-board redraw: `throttleMs: 1000` tells it 21 times in 20 s instead of 200. Absent or `0`: every change, as before. A non-finite or fractional value is `X_INVARIANT`; `schedule` injects the timer in a test |
| Knowing THAT records moved is `useRecordChanges(types, listener)` | one call per store update, the moved keys per type, and no hold on any row or type, so a page that re-renders on a channel's `records` pins nothing. `useChannel(...).error()` is the node's refusal of a `failed` membership |
| Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
| The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
Expand Down
115 changes: 115 additions & 0 deletions packages/realtime/src/record-answer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// `keptAnswer` and `throttledNotify` on their own: the identity rule a renderer stops on, and the
// leading-and-trailing period with no clock.

import { describe, expect, test } from 'bun:test';
import type { AsyncState } from '@ultimat3/core/page';
import { keptAnswer, sameRows, throttledNotify } from './record-answer';

/** An answer's rows, or `undefined` while pending or failed — what identity is asserted on. */
const dataOf = <R>(answer: AsyncState<R>): R | undefined =>
answer.status === 'ready' || answer.status === 'refreshing' ? answer.data : undefined;

describe('keptAnswer', () => {
test('the previous answer back when status and row objects match, a new one otherwise', () => {
const a = { id: 'a' };
const b = { id: 'b' };
const first = keptAnswer(null, 'ready', [a, b]);
expect(keptAnswer(first, 'ready', [a, b])).toBe(first);
const refreshing = keptAnswer(first, 'refreshing', [a, b]);
expect(refreshing).not.toBe(first);
expect(dataOf(refreshing)).toBe(dataOf(first));
// An equal row that is another object is a move: the store keeps an unmoved row's object.
expect(keptAnswer(first, 'ready', [a, { id: 'b' }])).not.toBe(first);
expect(keptAnswer(first, 'ready', [b, a])).not.toBe(first);
expect(keptAnswer({ status: 'pending' }, 'ready', [])).toEqual({ status: 'ready', data: [] });
});

test('sameRows compares by element identity and length', () => {
const a = { id: 'a' };
expect(sameRows([a], [a])).toBe(true);
expect(sameRows([a], [a, a])).toBe(false);
expect(sameRows([a], [{ id: 'a' }])).toBe(false);
});
});

describe('throttledNotify', () => {
test('absent or 0 is every change, synchronously, with no timer', () => {
let told = 0;
const armed: number[] = [];
for (const ms of [undefined, 0]) {
const notifier = throttledNotify(
() => told++,
ms,
(_, after) => {
armed.push(after);
return () => undefined;
},
);
notifier.call();
notifier.call();
}
expect(told).toBe(4);
expect(armed).toEqual([]);
});

test('a period with nothing owed closes; one with a change owed tells it and opens the next', () => {
let told = 0;
const timers: (() => void)[] = [];
const notifier = throttledNotify(
() => told++,
100,
(fn) => {
timers.push(fn);
return () => undefined;
},
);
notifier.call();
expect([told, timers.length]).toEqual([1, 1]);
notifier.call();
notifier.call();
timers.shift()?.();
expect([told, timers.length]).toEqual([2, 1]);
timers.shift()?.();
expect([told, timers.length]).toEqual([2, 0]);
notifier.call();
expect(told).toBe(3);
});

test('a reader that releases while it is told leaves no timer armed, now or at the period end', () => {
const armed = new Set<() => void>();
const notifier = throttledNotify(
() => notifier.cancel(),
100,
(fn) => {
armed.add(fn);
return () => armed.delete(fn);
},
);
notifier.call();
expect(armed.size).toBe(0);
notifier.call();
expect(armed.size).toBe(0);
});

test('a release inside the trailing notification arms no next period', () => {
const armed = new Set<() => void>();
let released = false;
const notifier = throttledNotify(
() => {
if (released) notifier.cancel();
},
100,
(fn) => {
armed.add(fn);
return () => armed.delete(fn);
},
);
notifier.call();
notifier.call();
released = true;
const [fire] = [...armed];
armed.clear();
fire?.();
expect(armed.size).toBe(0);
});
});
99 changes: 99 additions & 0 deletions packages/realtime/src/record-answer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// What a read hook hands back when nothing it shows moved: the SAME answer object. Solid compares a
// memo's value with `===`, so a fresh array of the same rows re-ran every derivation below it — a
// panel had to diff rows by hand (`stableRuns`, `sameData`) to keep a board still. And how often a
// hook may tell its readers at all: `throttleMs`, the first change at once, a burst as one.

import type { AsyncState } from '@ultimat3/core/page';
import { finiteCount } from '@ultimat3/core/page';
import { type Scheduler, timeoutScheduler } from './thundering-herd';

/** Same length, same row OBJECTS in the same order. The store keeps an unmoved row's object. */
export function sameRows<R>(a: readonly R[], b: readonly R[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
return true;
}

/**
* The rows to answer with: the previous answer's own array when it holds exactly these rows, so a
* re-read that found nothing new (a poll, a refetch, a change to a row the list does not show)
* hands every reader the value it already holds.
*/
export function keptRows<R>(previous: AsyncState<readonly R[]> | null, rows: readonly R[]) {
const held = previous !== null && 'data' in previous ? previous.data : undefined;
return held !== undefined && sameRows(held, rows) ? held : rows;
}

/** `{ status, data }`, reusing `previous` when both match — the identity a renderer stops on. */
export function keptAnswer<R>(
previous: AsyncState<readonly R[]> | null,
status: 'ready' | 'refreshing',
rows: readonly R[],
): AsyncState<readonly R[]> {
const data = keptRows(previous, rows);
if (
previous !== null &&
previous.status === status &&
'data' in previous &&
previous.data === data
)
return previous;
return { status, data };
}

export interface Notifier {
/** A change readers should hear: now, or at the end of the open period. */
readonly call: () => void;
/** The hook let go: an owed notification is dropped with it. */
readonly cancel: () => void;
}

/**
* `notify` at most once per `ms`: a change with no period open is told AT ONCE (a lone update is
* never delayed) and opens one; changes inside it are told ONCE at its end, which opens the next.
* The last change always lands. No clock: the scheduler's timer is the period. `0` or absent: every
* change, synchronously — what a hook did before the option existed.
*/
export function throttledNotify(
notify: () => void,
ms: number | undefined,
schedule: Scheduler = timeoutScheduler,
): Notifier {
if (ms === undefined || finiteCount('useRecords', 'throttleMs', ms) === 0) {
return { call: notify, cancel: () => undefined };
}
let cancelTimer: (() => void) | null = null;
let owed = false;
/** Released: nothing is armed or told again, whatever a notification's reader did meanwhile. */
let closed = false;
// The period is armed BEFORE the readers hear: a reader that releases the hook while it is told
// cancels this timer, rather than the hook arming one after its own release.
const open = (): void => {
if (closed) return;
cancelTimer = schedule(() => {
cancelTimer = null;
if (!owed || closed) return;
owed = false;
open();
notify();
}, ms);
};
return {
call: () => {
if (closed) return;
if (cancelTimer !== null) {
owed = true;
return;
}
open();
notify();
},
cancel: () => {
closed = true;
cancelTimer?.();
cancelTimer = null;
owed = false;
},
};
}
Loading
Loading