@ultimat3/realtime 29.0.0 → 29.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -6
- package/README.md +3 -0
- package/package.json +5 -5
- package/src/record-answer.ts +99 -0
- package/src/record-store.ts +4 -4
- package/src/record-synced.ts +48 -9
- package/src/use-query.ts +14 -9
- package/src/use-record.ts +59 -13
package/CLAUDE.md
CHANGED
|
@@ -164,8 +164,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
164
164
|
`upgrade()`, as Bun does.
|
|
165
165
|
- **A grant expires; a socket does not**: expired grants are re-decided on an interval
|
|
166
166
|
(`hub.onActorChange`, `registry.reauthorize`). No `refresh` = close `1008`; one that raises retries.
|
|
167
|
-
- **Every `socket.send` reads its answer**: a dropped subscribe reply marks it desynced; a dropped
|
|
168
|
-
roster logs `sync.presence_roster_dropped`; `drain()` returns `DrainedSocket[]` (`notified`).
|
|
169
167
|
- **Inbound frames run in a lane, never the socket**: `subscribe` is `sub:<sid>` or `topic:<name>`,
|
|
170
168
|
everything else unlaned (`frame-lanes.ts`); a lane exists only while work is queued.
|
|
171
169
|
- **A dropped `records` frame is counted AND repaired**: per-node `seq`/`epoch` per channel; a refused
|
|
@@ -256,6 +254,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
256
254
|
`useQuery`'s input is read once, at call time.
|
|
257
255
|
- **`useRecords(type, selection)` holds the TYPE** (`retainType`): `ready` at once, recomputed per
|
|
258
256
|
change to that type, no row of it evicted while held; the last type hold evicts what it spared.
|
|
257
|
+
- **Read hooks answer the SAME object until what they show moved** (`keptAnswer`); a `where`
|
|
258
|
+
reader hears only keys it shows or keeps (history, 2026-10-29).
|
|
259
259
|
- Every subscription handle is `Disposable`; `[Symbol.dispose]` IS the release. Type claims go in
|
|
260
260
|
`type-pins.ts`.
|
|
261
261
|
- **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time; `#epoch` is
|
|
@@ -282,9 +282,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
282
282
|
per socket; the engine reaps ports on the longer of its own and the node's.
|
|
283
283
|
- **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
|
|
284
284
|
`BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.
|
|
285
|
-
|
|
286
|
-
## Rules for code here
|
|
287
|
-
|
|
288
285
|
- Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`monotonic()` for durations).
|
|
289
286
|
|
|
290
287
|
## The page boot
|
|
@@ -312,7 +309,6 @@ bun test packages/realtime/src # from the REPO ROOT
|
|
|
312
309
|
bun run typecheck
|
|
313
310
|
```
|
|
314
311
|
|
|
315
|
-
`bunfig.toml`'s preload (matchers) is read from the cwd.
|
|
316
312
|
|
|
317
313
|
Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
|
|
318
314
|
without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
|
package/README.md
CHANGED
|
@@ -251,6 +251,7 @@ const pair = useRecords('posts', [postId]); // those key
|
|
|
251
251
|
const runs = useRecords<Run>('runs', { // EVERY `runs` record on the page
|
|
252
252
|
where: (run) => run.state !== 'done',
|
|
253
253
|
order: (a, b) => (a.startedAt < b.startedAt ? 1 : -1),
|
|
254
|
+
throttleMs: 1_000, // optional: at most once a second
|
|
254
255
|
});
|
|
255
256
|
const like = useMutation(LIKE_POST); // await like(input); like.pending
|
|
256
257
|
const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
|
|
@@ -279,6 +280,8 @@ same type `@ultimat3/ui` renders.
|
|
|
279
280
|
| 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) |
|
|
280
281
|
| 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 |
|
|
281
282
|
| 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) |
|
|
283
|
+
| 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) |
|
|
284
|
+
| 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 |
|
|
282
285
|
| 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 |
|
|
283
286
|
| Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
|
|
284
287
|
| The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "29.
|
|
3
|
+
"version": "29.2.0",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/core": "29.
|
|
44
|
-
"@ultimat3/entity": "29.
|
|
45
|
-
"@ultimat3/policy": "29.
|
|
46
|
-
"@ultimat3/query": "29.
|
|
43
|
+
"@ultimat3/core": "29.2.0",
|
|
44
|
+
"@ultimat3/entity": "29.2.0",
|
|
45
|
+
"@ultimat3/policy": "29.2.0",
|
|
46
|
+
"@ultimat3/query": "29.2.0",
|
|
47
47
|
"nats": "2.29.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// What a read hook hands back when nothing it shows moved: the SAME answer object. Solid compares a
|
|
2
|
+
// memo's value with `===`, so a fresh array of the same rows re-ran every derivation below it — a
|
|
3
|
+
// panel had to diff rows by hand (`stableRuns`, `sameData`) to keep a board still. And how often a
|
|
4
|
+
// hook may tell its readers at all: `throttleMs`, the first change at once, a burst as one.
|
|
5
|
+
|
|
6
|
+
import type { AsyncState } from '@ultimat3/core/page';
|
|
7
|
+
import { finiteCount } from '@ultimat3/core/page';
|
|
8
|
+
import { type Scheduler, timeoutScheduler } from './thundering-herd';
|
|
9
|
+
|
|
10
|
+
/** Same length, same row OBJECTS in the same order. The store keeps an unmoved row's object. */
|
|
11
|
+
export function sameRows<R>(a: readonly R[], b: readonly R[]): boolean {
|
|
12
|
+
if (a === b) return true;
|
|
13
|
+
if (a.length !== b.length) return false;
|
|
14
|
+
for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The rows to answer with: the previous answer's own array when it holds exactly these rows, so a
|
|
20
|
+
* re-read that found nothing new (a poll, a refetch, a change to a row the list does not show)
|
|
21
|
+
* hands every reader the value it already holds.
|
|
22
|
+
*/
|
|
23
|
+
export function keptRows<R>(previous: AsyncState<readonly R[]> | null, rows: readonly R[]) {
|
|
24
|
+
const held = previous !== null && 'data' in previous ? previous.data : undefined;
|
|
25
|
+
return held !== undefined && sameRows(held, rows) ? held : rows;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `{ status, data }`, reusing `previous` when both match — the identity a renderer stops on. */
|
|
29
|
+
export function keptAnswer<R>(
|
|
30
|
+
previous: AsyncState<readonly R[]> | null,
|
|
31
|
+
status: 'ready' | 'refreshing',
|
|
32
|
+
rows: readonly R[],
|
|
33
|
+
): AsyncState<readonly R[]> {
|
|
34
|
+
const data = keptRows(previous, rows);
|
|
35
|
+
if (
|
|
36
|
+
previous !== null &&
|
|
37
|
+
previous.status === status &&
|
|
38
|
+
'data' in previous &&
|
|
39
|
+
previous.data === data
|
|
40
|
+
)
|
|
41
|
+
return previous;
|
|
42
|
+
return { status, data };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Notifier {
|
|
46
|
+
/** A change readers should hear: now, or at the end of the open period. */
|
|
47
|
+
readonly call: () => void;
|
|
48
|
+
/** The hook let go: an owed notification is dropped with it. */
|
|
49
|
+
readonly cancel: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `notify` at most once per `ms`: a change with no period open is told AT ONCE (a lone update is
|
|
54
|
+
* never delayed) and opens one; changes inside it are told ONCE at its end, which opens the next.
|
|
55
|
+
* The last change always lands. No clock: the scheduler's timer is the period. `0` or absent: every
|
|
56
|
+
* change, synchronously — what a hook did before the option existed.
|
|
57
|
+
*/
|
|
58
|
+
export function throttledNotify(
|
|
59
|
+
notify: () => void,
|
|
60
|
+
ms: number | undefined,
|
|
61
|
+
schedule: Scheduler = timeoutScheduler,
|
|
62
|
+
): Notifier {
|
|
63
|
+
if (ms === undefined || finiteCount('useRecords', 'throttleMs', ms) === 0) {
|
|
64
|
+
return { call: notify, cancel: () => undefined };
|
|
65
|
+
}
|
|
66
|
+
let cancelTimer: (() => void) | null = null;
|
|
67
|
+
let owed = false;
|
|
68
|
+
/** Released: nothing is armed or told again, whatever a notification's reader did meanwhile. */
|
|
69
|
+
let closed = false;
|
|
70
|
+
// The period is armed BEFORE the readers hear: a reader that releases the hook while it is told
|
|
71
|
+
// cancels this timer, rather than the hook arming one after its own release.
|
|
72
|
+
const open = (): void => {
|
|
73
|
+
if (closed) return;
|
|
74
|
+
cancelTimer = schedule(() => {
|
|
75
|
+
cancelTimer = null;
|
|
76
|
+
if (!owed || closed) return;
|
|
77
|
+
owed = false;
|
|
78
|
+
open();
|
|
79
|
+
notify();
|
|
80
|
+
}, ms);
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
call: () => {
|
|
84
|
+
if (closed) return;
|
|
85
|
+
if (cancelTimer !== null) {
|
|
86
|
+
owed = true;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
open();
|
|
90
|
+
notify();
|
|
91
|
+
},
|
|
92
|
+
cancel: () => {
|
|
93
|
+
closed = true;
|
|
94
|
+
cancelTimer?.();
|
|
95
|
+
cancelTimer = null;
|
|
96
|
+
owed = false;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/record-store.ts
CHANGED
|
@@ -81,8 +81,8 @@ export class RecordStore implements RecordSink {
|
|
|
81
81
|
seen.add(rk);
|
|
82
82
|
if (row !== null) out.push([rk.slice(prefix.length), row]);
|
|
83
83
|
}
|
|
84
|
-
for (const [rk, row] of this.#synced.
|
|
85
|
-
if (
|
|
84
|
+
for (const [rk, row] of this.#synced.ofType(type)) {
|
|
85
|
+
if (!seen.has(rk)) out.push([rk.slice(prefix.length), row]);
|
|
86
86
|
}
|
|
87
87
|
return out;
|
|
88
88
|
}
|
|
@@ -133,7 +133,7 @@ export class RecordStore implements RecordSink {
|
|
|
133
133
|
for (const [key, row] of Object.entries(rows)) {
|
|
134
134
|
const rk = recordKey(type, key);
|
|
135
135
|
if (!isRow(row) || key === '') continue;
|
|
136
|
-
if (this.#synced.restore(rk, row)) this.#touchSynced(rk);
|
|
136
|
+
if (this.#synced.restore(rk, row, type)) this.#touchSynced(rk);
|
|
137
137
|
}
|
|
138
138
|
});
|
|
139
139
|
}
|
|
@@ -141,7 +141,7 @@ export class RecordStore implements RecordSink {
|
|
|
141
141
|
/** One server write, merged column by column (`SyncedLayer.merge`). */
|
|
142
142
|
merge(type: string, key: string, columns: Row): Row {
|
|
143
143
|
const rk = recordKey(type, key);
|
|
144
|
-
const { row, changed } = this.#synced.merge(rk, columns);
|
|
144
|
+
const { row, changed } = this.#synced.merge(rk, columns, type);
|
|
145
145
|
if (changed) {
|
|
146
146
|
this.#touchSynced(rk);
|
|
147
147
|
} else if (this.#overlays.size > 0) {
|
package/src/record-synced.ts
CHANGED
|
@@ -15,6 +15,16 @@ export class SyncedLayer {
|
|
|
15
15
|
readonly #spared = new Map<string, Set<RecordKey>>();
|
|
16
16
|
/** Keys restored from disk and not yet confirmed: the first server row REPLACES, never merges. */
|
|
17
17
|
readonly #provisional = new Set<RecordKey>();
|
|
18
|
+
/**
|
|
19
|
+
* Per type, its keys: a whole-type read walks its own rows, never every row of every type. A
|
|
20
|
+
* run page holds the fleet's runs beside one run's events, and each event reader scanned both.
|
|
21
|
+
*/
|
|
22
|
+
readonly #byType = new Map<string, Set<RecordKey>>();
|
|
23
|
+
/**
|
|
24
|
+
* Each indexed key's type, as its writer NAMED it. Never recovered from the key: a type may hold
|
|
25
|
+
* a colon (`?query:<name>`, an unnamed live query's rows), so `type:key` splits two ways.
|
|
26
|
+
*/
|
|
27
|
+
readonly #typeOfKey = new Map<RecordKey, string>();
|
|
18
28
|
|
|
19
29
|
get(rk: RecordKey): Row | undefined {
|
|
20
30
|
return this.#rows.get(rk);
|
|
@@ -34,8 +44,12 @@ export class SyncedLayer {
|
|
|
34
44
|
|
|
35
45
|
/** Every row of one type — what a replay's `tx.<type>.all()` reads under the overlay. */
|
|
36
46
|
ofType(type: string): readonly [RecordKey, Row][] {
|
|
37
|
-
const
|
|
38
|
-
|
|
47
|
+
const out: [RecordKey, Row][] = [];
|
|
48
|
+
for (const rk of this.#byType.get(type) ?? []) {
|
|
49
|
+
const row = this.#rows.get(rk);
|
|
50
|
+
if (row !== undefined) out.push([rk, row]);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
isProvisional(rk: RecordKey): boolean {
|
|
@@ -49,7 +63,11 @@ export class SyncedLayer {
|
|
|
49
63
|
* a JSON column is a fresh array or object on every read, and by identity every catch-up, poll
|
|
50
64
|
* and reconnect moved every row. An unmoved column keeps its object, so its readers stay put.
|
|
51
65
|
*/
|
|
52
|
-
merge(
|
|
66
|
+
merge(
|
|
67
|
+
rk: RecordKey,
|
|
68
|
+
columns: Row,
|
|
69
|
+
type: string,
|
|
70
|
+
): { readonly row: Row; readonly changed: boolean } {
|
|
53
71
|
// A restored row is a guess from disk: the server's first answer is the whole truth.
|
|
54
72
|
const current = this.#provisional.delete(rk) ? undefined : this.#rows.get(rk);
|
|
55
73
|
const next: Record<string, unknown> = { ...current };
|
|
@@ -62,19 +80,20 @@ export class SyncedLayer {
|
|
|
62
80
|
}
|
|
63
81
|
if (!changed && current !== undefined) return { row: current, changed: false };
|
|
64
82
|
const row = Object.freeze(next);
|
|
65
|
-
this.#
|
|
83
|
+
this.#put(rk, row, type);
|
|
66
84
|
return { row, changed: true };
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
/** A conflict policy's verdict replacing the server row outright. */
|
|
70
88
|
set(rk: RecordKey, row: Row): void {
|
|
71
|
-
|
|
89
|
+
// Only ever over a row the server already holds: its type was named when it arrived.
|
|
90
|
+
this.#put(rk, Object.freeze({ ...row }), this.#typeOfKey.get(rk) ?? typeOf(rk));
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
/** A row from disk, only where the server has not answered. Answers whether it went in. */
|
|
75
|
-
restore(rk: RecordKey, row: Row): boolean {
|
|
94
|
+
restore(rk: RecordKey, row: Row, type: string): boolean {
|
|
76
95
|
if (this.#rows.has(rk)) return false;
|
|
77
|
-
this.#
|
|
96
|
+
this.#put(rk, Object.freeze({ ...row }), type);
|
|
78
97
|
this.#provisional.add(rk);
|
|
79
98
|
return true;
|
|
80
99
|
}
|
|
@@ -83,7 +102,12 @@ export class SyncedLayer {
|
|
|
83
102
|
remove(rk: RecordKey): boolean {
|
|
84
103
|
this.#provisional.delete(rk);
|
|
85
104
|
// Gone is no longer spared: a row the server sends again later is a new arrival, not this one.
|
|
86
|
-
this.#
|
|
105
|
+
const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
|
|
106
|
+
this.#typeOfKey.delete(rk);
|
|
107
|
+
this.#spared.get(type)?.delete(rk);
|
|
108
|
+
const keys = this.#byType.get(type);
|
|
109
|
+
keys?.delete(rk);
|
|
110
|
+
if (keys?.size === 0) this.#byType.delete(type);
|
|
87
111
|
return this.#rows.delete(rk);
|
|
88
112
|
}
|
|
89
113
|
|
|
@@ -103,7 +127,7 @@ export class SyncedLayer {
|
|
|
103
127
|
return false;
|
|
104
128
|
}
|
|
105
129
|
this.#holds.delete(rk);
|
|
106
|
-
const type =
|
|
130
|
+
const type = this.#typeOfKey.get(rk) ?? typeOf(rk);
|
|
107
131
|
if (this.#typeHolds.has(type)) {
|
|
108
132
|
const spared = this.#spared.get(type) ?? new Set<RecordKey>();
|
|
109
133
|
spared.add(rk);
|
|
@@ -140,7 +164,22 @@ export class SyncedLayer {
|
|
|
140
164
|
|
|
141
165
|
clear(): void {
|
|
142
166
|
this.#rows.clear();
|
|
167
|
+
this.#byType.clear();
|
|
168
|
+
this.#typeOfKey.clear();
|
|
143
169
|
this.#provisional.clear();
|
|
144
170
|
this.#spared.clear();
|
|
145
171
|
}
|
|
172
|
+
|
|
173
|
+
#put(rk: RecordKey, row: Row, type: string): void {
|
|
174
|
+
if (!this.#typeOfKey.has(rk)) {
|
|
175
|
+
this.#typeOfKey.set(rk, type);
|
|
176
|
+
const keys = this.#byType.get(type);
|
|
177
|
+
if (keys === undefined) this.#byType.set(type, new Set([rk]));
|
|
178
|
+
else keys.add(rk);
|
|
179
|
+
}
|
|
180
|
+
this.#rows.set(rk, row);
|
|
181
|
+
}
|
|
146
182
|
}
|
|
183
|
+
|
|
184
|
+
/** A key's type when no writer named it (a hold on a row not yet there): before its first `:`. */
|
|
185
|
+
const typeOf = (rk: RecordKey): string => rk.slice(0, rk.indexOf(':'));
|
package/src/use-query.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { LiveHandle } from './client-contract';
|
|
|
8
8
|
import type { JsonValue } from './json';
|
|
9
9
|
import { installedPage, type PageServices } from './page-store';
|
|
10
10
|
import { isServerRender, signalFor } from './reactivity';
|
|
11
|
+
import { keptAnswer } from './record-answer';
|
|
11
12
|
import { type RecordStore, recordKey } from './record-store';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -88,16 +89,17 @@ function liveAccessor<R extends object>(
|
|
|
88
89
|
if (handle.state() === 'live') seen = true;
|
|
89
90
|
bump();
|
|
90
91
|
});
|
|
92
|
+
/** The last answer handed out: the same rows back keep its `data` array (`keptAnswer`). */
|
|
93
|
+
let answer: AsyncState<readonly R[]> | null = null;
|
|
91
94
|
const read = (): AsyncState<readonly R[]> => {
|
|
92
95
|
version();
|
|
93
96
|
const state = handle.state();
|
|
94
97
|
if (state === 'failed') return { status: 'failed', error: handle.error() };
|
|
95
|
-
if (state === 'live')
|
|
96
|
-
seen = true;
|
|
97
|
-
return { status: 'ready', data: handle.rows() };
|
|
98
|
-
}
|
|
98
|
+
if (state === 'live') seen = true;
|
|
99
99
|
// Loading, stale or offline: what was shown stays shown, marked busy — never torn down.
|
|
100
|
-
|
|
100
|
+
else if (!seen) return PENDING;
|
|
101
|
+
answer = keptAnswer(answer, state === 'live' ? 'ready' : 'refreshing', handle.rows());
|
|
102
|
+
return answer;
|
|
101
103
|
};
|
|
102
104
|
const release = (): void => {
|
|
103
105
|
off();
|
|
@@ -226,14 +228,17 @@ function readAccessor<R extends object>(
|
|
|
226
228
|
};
|
|
227
229
|
|
|
228
230
|
load();
|
|
231
|
+
/**
|
|
232
|
+
* The last answer handed out: a re-read that answered the same rows (a poll, a refetch, a return
|
|
233
|
+
* to the tab) hands back its `data` array, so nothing derived from it runs again.
|
|
234
|
+
*/
|
|
235
|
+
let answer: AsyncState<readonly R[]> | null = null;
|
|
229
236
|
const read = (): AsyncState<readonly R[]> => {
|
|
230
237
|
version();
|
|
231
238
|
if (state.status === 'pending' || state.status === 'failed') return state;
|
|
232
239
|
// Rows are typed by the caller's query; the store holds them as JSON rows.
|
|
233
|
-
|
|
234
|
-
return
|
|
235
|
-
? { status: 'refreshing', data }
|
|
236
|
-
: { status: 'ready', data };
|
|
240
|
+
answer = keptAnswer(answer, state.status, rows() as readonly R[]);
|
|
241
|
+
return answer;
|
|
237
242
|
};
|
|
238
243
|
const release = (): void => {
|
|
239
244
|
if (released) return;
|
package/src/use-record.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import type { AsyncState, Row } from '@ultimat3/core/page';
|
|
7
7
|
import { pageStore } from './page-store';
|
|
8
8
|
import { isServerRender, signalFor } from './reactivity';
|
|
9
|
-
import {
|
|
9
|
+
import { keptAnswer, throttledNotify } from './record-answer';
|
|
10
|
+
import { type RecordKey, type RecordStore, recordKey } from './record-store';
|
|
11
|
+
import type { Scheduler } from './thundering-herd';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* A callable `AsyncState`: `pending` until the record first arrives, `ready` from then on —
|
|
@@ -43,12 +45,16 @@ export function useRecord<R extends object = Row>(type: string, key: string): Re
|
|
|
43
45
|
if (store.peek(type, key) !== undefined) seen = true;
|
|
44
46
|
setVersion(version() + 1);
|
|
45
47
|
});
|
|
48
|
+
let answer: AsyncState<R | undefined> = PENDING;
|
|
46
49
|
const read = (): AsyncState<R | undefined> => {
|
|
47
50
|
version();
|
|
48
51
|
// The store holds JSON rows; the caller names the entity row type it reads them as.
|
|
49
52
|
const row = store.peek(type, key) as R | undefined;
|
|
50
53
|
if (row !== undefined) seen = true;
|
|
51
|
-
|
|
54
|
+
if (row === undefined && !seen) return PENDING;
|
|
55
|
+
// The same row object back is the same answer: nothing reading it runs again.
|
|
56
|
+
if (answer.status !== 'ready' || answer.data !== row) answer = { status: 'ready', data: row };
|
|
57
|
+
return answer;
|
|
52
58
|
};
|
|
53
59
|
let held = true;
|
|
54
60
|
const release = (): void => {
|
|
@@ -75,6 +81,14 @@ export interface RecordSelection<R extends object = Row> {
|
|
|
75
81
|
readonly where?: (record: R) => boolean;
|
|
76
82
|
/** Absent: by record key, ascending — creation order for a uuid v7 key. */
|
|
77
83
|
readonly order?: (a: R, b: R) => number;
|
|
84
|
+
/**
|
|
85
|
+
* Tell readers at most once per this many ms: the first change at once, a burst as ONE change at
|
|
86
|
+
* the end of the period, the last always landing. For a projection a busy feed moves many times
|
|
87
|
+
* a second (a live board on a phone). Absent or `0`: every change, as it lands.
|
|
88
|
+
*/
|
|
89
|
+
readonly throttleMs?: number;
|
|
90
|
+
/** The period's timer. Defaults to `setTimeout`; a test injects its own. */
|
|
91
|
+
readonly schedule?: Scheduler;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
/**
|
|
@@ -131,16 +145,22 @@ function byKeys<R extends object>(
|
|
|
131
145
|
for (const key of keys) store.retain(type, key);
|
|
132
146
|
});
|
|
133
147
|
let seen = present().length > 0;
|
|
148
|
+
let answer: AsyncState<readonly R[]> | null = null;
|
|
134
149
|
const unsubscribe = store.subscribe((changed) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
150
|
+
for (const rk of changed) {
|
|
151
|
+
if (!targets.has(rk)) continue;
|
|
152
|
+
if (present().length > 0) seen = true;
|
|
153
|
+
bump();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
138
156
|
});
|
|
139
157
|
const read = (): AsyncState<readonly R[]> => {
|
|
140
158
|
version();
|
|
141
159
|
const rows = present();
|
|
142
160
|
if (rows.length > 0) seen = true;
|
|
143
|
-
|
|
161
|
+
if (!seen) return PENDING;
|
|
162
|
+
answer = keptAnswer(answer, 'ready', rows);
|
|
163
|
+
return answer;
|
|
144
164
|
};
|
|
145
165
|
let held = true;
|
|
146
166
|
const release = (): void => {
|
|
@@ -163,15 +183,36 @@ function ofType<R extends object>(
|
|
|
163
183
|
): RecordsAccessor<R> {
|
|
164
184
|
const prefix = `${type}:`;
|
|
165
185
|
const { where, order } = selection;
|
|
186
|
+
const notify = throttledNotify(bump, selection.throttleMs, selection.schedule);
|
|
166
187
|
store.retainType(type);
|
|
167
188
|
// Recomputed once per change to this type, never per read: the same answer back is what tells
|
|
168
189
|
// a fine-grained renderer nothing moved.
|
|
169
190
|
let answer: AsyncState<readonly R[]> | null = null;
|
|
170
191
|
let answered = -1;
|
|
192
|
+
/** The keys the current answer shows: a change to one of them moves it, whatever `where` says. */
|
|
193
|
+
let shown: ReadonlySet<RecordKey> = new Set();
|
|
194
|
+
/**
|
|
195
|
+
* Whether `rk` can move the answer readers hold: shown in it, or kept by `where` now. A record of
|
|
196
|
+
* the type the list neither shows nor would show — the fleet's run beside one viewer's — is
|
|
197
|
+
* nobody's change here, and told to nobody (a stale answer is told regardless: it is re-read).
|
|
198
|
+
*/
|
|
199
|
+
const moves = (rk: RecordKey): boolean => {
|
|
200
|
+
if (where === undefined || answer === null || answered !== version() || shown.has(rk)) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
// The store holds JSON rows; the caller names the entity row type it reads them as.
|
|
204
|
+
const row = store.peek(type, rk.slice(prefix.length)) as R | undefined;
|
|
205
|
+
try {
|
|
206
|
+
return row !== undefined && where(row);
|
|
207
|
+
} catch {
|
|
208
|
+
// The read raises it to the caller; a listener that threw would silence the page's others.
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
171
212
|
const unsubscribe = store.subscribe((changed) => {
|
|
172
213
|
for (const rk of changed) {
|
|
173
|
-
if (!rk.startsWith(prefix)) continue;
|
|
174
|
-
|
|
214
|
+
if (!rk.startsWith(prefix) || !moves(rk)) continue;
|
|
215
|
+
notify.call();
|
|
175
216
|
return;
|
|
176
217
|
}
|
|
177
218
|
});
|
|
@@ -181,11 +222,15 @@ function ofType<R extends object>(
|
|
|
181
222
|
// The store holds JSON rows; the caller names the entity row type it reads them as.
|
|
182
223
|
const keyed = store.keyed(type) as readonly (readonly [string, R])[];
|
|
183
224
|
const kept = where === undefined ? [...keyed] : keyed.filter(([, row]) => where(row));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
answer =
|
|
225
|
+
if (order === undefined) kept.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
226
|
+
else kept.sort(([, a], [, b]) => order(a, b));
|
|
227
|
+
// Only a `where` asks what is shown: with none, every record of the type moves the answer.
|
|
228
|
+
if (where !== undefined) shown = new Set(kept.map(([key]) => `${prefix}${key}`));
|
|
229
|
+
answer = keptAnswer(
|
|
230
|
+
answer,
|
|
231
|
+
'ready',
|
|
232
|
+
kept.map(([, row]) => row),
|
|
233
|
+
);
|
|
189
234
|
answered = at;
|
|
190
235
|
return answer;
|
|
191
236
|
};
|
|
@@ -193,6 +238,7 @@ function ofType<R extends object>(
|
|
|
193
238
|
const release = (): void => {
|
|
194
239
|
if (!held) return;
|
|
195
240
|
held = false;
|
|
241
|
+
notify.cancel();
|
|
196
242
|
unsubscribe();
|
|
197
243
|
store.releaseType(type);
|
|
198
244
|
};
|