@ultimat3/realtime 27.14.0 → 27.15.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 +4 -4
- package/README.md +2 -1
- package/package.json +5 -5
- package/src/client-heartbeat.ts +9 -1
- package/src/client.ts +25 -1
- package/src/index.ts +5 -0
- package/src/json.ts +20 -5
- package/src/live-replicator.ts +5 -2
- package/src/page-socket.ts +8 -0
- package/src/record-synced.ts +6 -2
- package/src/record-tx.ts +6 -2
- package/src/use-channel.ts +10 -2
- package/src/use-record-changes.ts +47 -0
package/CLAUDE.md
CHANGED
|
@@ -103,10 +103,10 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
103
103
|
`staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
|
|
104
104
|
sequence — or a NEW producer after a known one — stales every window; `refillWindowInLane` replaces it.
|
|
105
105
|
- **A bulk write stales only its entity's readers**: windows + channel topics, `invalidate(entity)`
|
|
106
|
-
on both from `onBulk
|
|
106
|
+
on both from `onBulk` (channels: not a `recordPublisher` table). **A stale window's re-read re-snapshots EVERY subscriber**, before the lsn
|
|
107
107
|
guard, even on a non-match (`live-replicator-bulk`, `live-fanout` tests).
|
|
108
108
|
- **A WINDOW is per TENANT**: its id is `windowId(queryHash(name, input), tenant)` (`live-tenant.ts`;
|
|
109
|
-
tenant = the subscriber's `actor.orgId`).
|
|
109
|
+
tenant = the subscriber's `actor.orgId`). The read runs `readFor`
|
|
110
110
|
(`live-definition.ts`): the node's ctx, a service actor carrying ONLY that org — never the
|
|
111
111
|
subscriber. A cursor naming another window is a cold start; `reauthorize` re-seats a
|
|
112
112
|
subscription whose actor changed org (`live-tenancy.test.ts`; `docs/history/realtime.md`).
|
|
@@ -278,7 +278,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
278
278
|
(with its cursor) and per topic.
|
|
279
279
|
- **The client beats** (`DEFAULT_HEARTBEAT_MS` 10 s = default ttl / 3, pinned by
|
|
280
280
|
`client-heartbeat.test.ts`; `0` disables): a `hello` plus one subscribe per topic. Two silent
|
|
281
|
-
windows close with `4000`. **The node names the beat: `hello.heartbeatMs`** — `Heartbeat.follow`
|
|
281
|
+
windows after an UNANSWERED beat close with `4000`. **The node names the beat: `hello.heartbeatMs`** — `Heartbeat.follow`
|
|
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.
|
|
@@ -312,7 +312,7 @@ bun test packages/realtime/src # from the REPO ROOT
|
|
|
312
312
|
bun run typecheck
|
|
313
313
|
```
|
|
314
314
|
|
|
315
|
-
`bunfig.toml`'s preload
|
|
315
|
+
`bunfig.toml`'s preload (matchers) is read from the cwd.
|
|
316
316
|
|
|
317
317
|
Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
|
|
318
318
|
without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ class — stay on `.`.
|
|
|
84
84
|
| wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
|
|
85
85
|
| the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) |
|
|
86
86
|
| a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
87
|
-
| hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
|
|
87
|
+
| hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useRecordChanges`, `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
|
|
88
88
|
| channel records, no replicator | `./server` | `recordPublisher` — an app's committed rows as channel `records`, sequenced per producer ([below](#channel-records-with-no-replicator)) |
|
|
89
89
|
| channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
|
|
90
90
|
| first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`, for an island that reads the store or the outbox: the boot's restore and the open outbox, capped (#506). An island taking only `useChannel` / `usePresence` / `useConnection` never awaits it and is never hidden |
|
|
@@ -279,6 +279,7 @@ same type `@ultimat3/ui` renders.
|
|
|
279
279
|
| 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
280
|
| 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
281
|
| 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) |
|
|
282
|
+
| 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 |
|
|
282
283
|
| Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
|
|
283
284
|
| The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
|
|
284
285
|
| **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.15.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": "27.
|
|
44
|
-
"@ultimat3/entity": "27.
|
|
45
|
-
"@ultimat3/policy": "27.
|
|
46
|
-
"@ultimat3/query": "27.
|
|
43
|
+
"@ultimat3/core": "27.15.0",
|
|
44
|
+
"@ultimat3/entity": "27.15.0",
|
|
45
|
+
"@ultimat3/policy": "27.15.0",
|
|
46
|
+
"@ultimat3/query": "27.15.0",
|
|
47
47
|
"nats": "2.29.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/client-heartbeat.ts
CHANGED
|
@@ -39,6 +39,8 @@ export class Heartbeat {
|
|
|
39
39
|
readonly #options: HeartbeatOptions;
|
|
40
40
|
#cancel: (() => void) | null = null;
|
|
41
41
|
#lastSeen = 0;
|
|
42
|
+
/** Whether a frame arrived since this socket last asked (the opening `hello`, then each beat). */
|
|
43
|
+
#answered = false;
|
|
42
44
|
/** The beat in force on THIS socket: the option's, until the node's `hello` names another. */
|
|
43
45
|
#intervalMs: number;
|
|
44
46
|
|
|
@@ -58,6 +60,7 @@ export class Heartbeat {
|
|
|
58
60
|
this.#intervalMs = this.#options.intervalMs;
|
|
59
61
|
if (this.#intervalMs <= 0) return;
|
|
60
62
|
this.#lastSeen = now;
|
|
63
|
+
this.#answered = false;
|
|
61
64
|
this.#arm();
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -78,6 +81,7 @@ export class Heartbeat {
|
|
|
78
81
|
/** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
|
|
79
82
|
saw(now: number): void {
|
|
80
83
|
this.#lastSeen = now;
|
|
84
|
+
this.#answered = true;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
stop(): void {
|
|
@@ -98,10 +102,14 @@ export class Heartbeat {
|
|
|
98
102
|
// Two windows, not one: a beat and the answer to it share the window they were sent in, so a
|
|
99
103
|
// single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
|
|
100
104
|
// a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
|
|
101
|
-
|
|
105
|
+
// And only when the last beat went UNANSWERED: a hidden tab's timers are throttled (Chrome runs
|
|
106
|
+
// a chained timer about once a minute), so a late tick on a socket that answered every beat it
|
|
107
|
+
// was sent is the page's clock, not the socket — dropping it churned every background tab.
|
|
108
|
+
if (!this.#answered && now - this.#lastSeen > this.#intervalMs * 2) {
|
|
102
109
|
this.#options.onSilence();
|
|
103
110
|
return;
|
|
104
111
|
}
|
|
112
|
+
this.#answered = false;
|
|
105
113
|
this.#options.beat();
|
|
106
114
|
this.#arm();
|
|
107
115
|
}
|
package/src/client.ts
CHANGED
|
@@ -62,6 +62,8 @@ export class LiveClient {
|
|
|
62
62
|
#attempt = 0;
|
|
63
63
|
/** The armed reconnect's canceller, and the flag for "one timer in flight, the first wins". */
|
|
64
64
|
#reconnectTimer: (() => void) | null = null;
|
|
65
|
+
/** The armed wait is a node's drain slot: `wake()` leaves it alone, or the herd re-clusters. */
|
|
66
|
+
#armedByNode = false;
|
|
65
67
|
/** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */
|
|
66
68
|
#closed = false;
|
|
67
69
|
#connected = false;
|
|
@@ -157,7 +159,6 @@ export class LiveClient {
|
|
|
157
159
|
// and the one handler that had none. A replaced socket opening late would otherwise mark the
|
|
158
160
|
// live connection up and replay every subscription onto whatever socket is current.
|
|
159
161
|
if (this.#socket !== socket) return;
|
|
160
|
-
this.#attempt = 0;
|
|
161
162
|
this.#setStatus({ connected: true, reconnectAt: null });
|
|
162
163
|
// `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
|
|
163
164
|
// frame below, which is the only place resume is decided — sending it here too shipped every
|
|
@@ -174,6 +175,10 @@ export class LiveClient {
|
|
|
174
175
|
// that is still draining bytes would otherwise fold its patches into the live registrations
|
|
175
176
|
// a second time, over newer state.
|
|
176
177
|
if (this.#socket !== socket) return;
|
|
178
|
+
// The curve resets on the node's first word, not on the open: a proxy or an overloaded node
|
|
179
|
+
// that accepts the upgrade and drops it at once would otherwise reset it every time, and the
|
|
180
|
+
// tab redialled every half second for as long as that lasted.
|
|
181
|
+
this.#attempt = 0;
|
|
177
182
|
this.#heartbeat.saw(this.#clock.now().getTime());
|
|
178
183
|
applyFrame(decode(data), this.#frameTarget);
|
|
179
184
|
});
|
|
@@ -227,6 +232,24 @@ export class LiveClient {
|
|
|
227
232
|
this.#offline();
|
|
228
233
|
}
|
|
229
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The network or the tab is back (`online`, a tab made visible): dial now instead of sitting out
|
|
237
|
+
* the armed backoff. Only a local backoff is cut short — a node's drain slot is how a deploy's
|
|
238
|
+
* herd is spread, so it is kept — and only while a reconnect is actually armed.
|
|
239
|
+
*/
|
|
240
|
+
wake(): void {
|
|
241
|
+
if (this.#closed || this.#connected || this.#reconnectTimer === null || this.#armedByNode)
|
|
242
|
+
return;
|
|
243
|
+
this.#cancelReconnect();
|
|
244
|
+
this.#attempt = 0;
|
|
245
|
+
try {
|
|
246
|
+
this.connect();
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.#scheduleReconnect(null);
|
|
249
|
+
this.#onError(error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
230
253
|
/**
|
|
231
254
|
* Subscribe to a live query. The window holds ids; the rows are the page store's, so a record
|
|
232
255
|
* updated by an HTTP response or another window re-renders here too, with no second copy.
|
|
@@ -411,6 +434,7 @@ export class LiveClient {
|
|
|
411
434
|
// `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one.
|
|
412
435
|
serverDelayMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng);
|
|
413
436
|
this.#attempt += 1;
|
|
437
|
+
this.#armedByNode = serverDelayMs !== null;
|
|
414
438
|
this.#setStatus({ reconnectAt: this.#clock.now().getTime() + delay });
|
|
415
439
|
const schedule = this.#options.scheduler ?? timeoutScheduler;
|
|
416
440
|
this.#reconnectTimer = schedule(() => {
|
package/src/index.ts
CHANGED
package/src/json.ts
CHANGED
|
@@ -74,9 +74,24 @@ export function changedColumns(before: JsonObject | null, after: JsonObject): Js
|
|
|
74
74
|
return out;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Structural equality over the value domain: arrays by position, plain objects by key whatever
|
|
79
|
+
* their order (Postgres `jsonb` reorders keys), a `Date` by its time. A JSON column is a fresh
|
|
80
|
+
* object on every read, so identity said "changed" for every re-read of an unchanged row.
|
|
81
|
+
*/
|
|
82
|
+
export function sameJson(a: unknown, b: unknown): boolean {
|
|
83
|
+
if (Object.is(a, b)) return true;
|
|
84
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
85
|
+
if (a instanceof Date || b instanceof Date) {
|
|
86
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
89
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
90
|
+
return a.every((item, index) => sameJson(item, b[index]));
|
|
91
|
+
}
|
|
92
|
+
const left = a as Record<string, unknown>;
|
|
93
|
+
const right = b as Record<string, unknown>;
|
|
94
|
+
const keys = Object.keys(left);
|
|
95
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
96
|
+
return keys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
|
|
82
97
|
}
|
package/src/live-replicator.ts
CHANGED
|
@@ -151,8 +151,11 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
|
|
|
151
151
|
if (stopped) return;
|
|
152
152
|
const table = tableOf(change.entity);
|
|
153
153
|
registry.invalidate(table);
|
|
154
|
-
// Channels too, or a tab seated on one held the pre-update rows with nothing to re-read
|
|
155
|
-
|
|
154
|
+
// Channels too, or a tab seated on one held the pre-update rows with nothing to re-read —
|
|
155
|
+
// unless a `recordPublisher` claims the table, as `onChange` asks: the app's own publish is
|
|
156
|
+
// its channel delivery, bulk writes included, and a replay-gap on every `updateWhere` sent
|
|
157
|
+
// every seated tab back to a full catch-up read for rows it was already being sent.
|
|
158
|
+
if (!isPublishedTable(table)) options.channels?.invalidate(table);
|
|
156
159
|
},
|
|
157
160
|
};
|
|
158
161
|
|
package/src/page-socket.ts
CHANGED
|
@@ -66,16 +66,24 @@ export function pageSocket(hook: string): LiveClient {
|
|
|
66
66
|
host.rehost();
|
|
67
67
|
client.connect();
|
|
68
68
|
};
|
|
69
|
+
// The network or the tab came back: dial now, not at the end of a backoff armed while it was gone.
|
|
70
|
+
const wake = (): void => {
|
|
71
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'hidden') client.wake();
|
|
72
|
+
};
|
|
69
73
|
const listens = typeof addEventListener === 'function';
|
|
70
74
|
if (listens) {
|
|
71
75
|
addEventListener('pagehide', bye);
|
|
72
76
|
addEventListener('pageshow', restored);
|
|
77
|
+
addEventListener('online', wake);
|
|
78
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', wake);
|
|
73
79
|
}
|
|
74
80
|
teardowns.add(() => {
|
|
75
81
|
offRescope();
|
|
76
82
|
if (listens) {
|
|
77
83
|
removeEventListener('pagehide', bye);
|
|
78
84
|
removeEventListener('pageshow', restored);
|
|
85
|
+
removeEventListener('online', wake);
|
|
86
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', wake);
|
|
79
87
|
}
|
|
80
88
|
client.close();
|
|
81
89
|
host.bye();
|
package/src/record-synced.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// state — it answers what moved, and the store decides who hears about it.
|
|
4
4
|
|
|
5
5
|
import type { Row } from '@ultimat3/core/page';
|
|
6
|
+
import { sameJson } from './json';
|
|
6
7
|
import type { RecordKey } from './record-key';
|
|
7
8
|
|
|
8
9
|
export class SyncedLayer {
|
|
@@ -44,7 +45,9 @@ export class SyncedLayer {
|
|
|
44
45
|
/**
|
|
45
46
|
* One server write. Merged, never replaced: a projection that selected fewer columns must not
|
|
46
47
|
* blank the columns another is rendering, so an omitted (or `undefined`) field is left alone.
|
|
47
|
-
* Answers the row as it now stands, and whether anything in it changed.
|
|
48
|
+
* Answers the row as it now stands, and whether anything in it changed. Compared STRUCTURALLY:
|
|
49
|
+
* a JSON column is a fresh array or object on every read, and by identity every catch-up, poll
|
|
50
|
+
* and reconnect moved every row. An unmoved column keeps its object, so its readers stay put.
|
|
48
51
|
*/
|
|
49
52
|
merge(rk: RecordKey, columns: Row): { readonly row: Row; readonly changed: boolean } {
|
|
50
53
|
// A restored row is a guess from disk: the server's first answer is the whole truth.
|
|
@@ -53,7 +56,8 @@ export class SyncedLayer {
|
|
|
53
56
|
let changed = current === undefined;
|
|
54
57
|
for (const [column, value] of Object.entries(columns)) {
|
|
55
58
|
if (value === undefined) continue;
|
|
56
|
-
if (current
|
|
59
|
+
if (current !== undefined && sameJson(current[column], value)) continue;
|
|
60
|
+
changed = true;
|
|
57
61
|
next[column] = value;
|
|
58
62
|
}
|
|
59
63
|
if (!changed && current !== undefined) return { row: current, changed: false };
|
package/src/record-tx.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// over the OLD row must become `+ 1` over the row the server just sent, never the stale sum.
|
|
4
4
|
|
|
5
5
|
import type { ConflictPolicy, Row } from '@ultimat3/core/page';
|
|
6
|
+
import { sameJson } from './json';
|
|
6
7
|
|
|
7
8
|
/** One table as a mutator's `local` half sees it, keyed by record type (the entity's name). */
|
|
8
9
|
export interface LocalTable<R extends Row = Row> {
|
|
@@ -45,13 +46,16 @@ export interface ReplayResult {
|
|
|
45
46
|
readonly failed: readonly string[];
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Content equality, column by column and STRUCTURAL within one (`sameJson`) — a replay producing
|
|
51
|
+
* the same columns must not re-render anything, and a twin's array is a fresh one every replay.
|
|
52
|
+
*/
|
|
49
53
|
export function sameRow(a: Row | null, b: Row | null): boolean {
|
|
50
54
|
if (a === b) return true;
|
|
51
55
|
if (a === null || b === null) return false;
|
|
52
56
|
const keys = Object.keys(a);
|
|
53
57
|
if (keys.length !== Object.keys(b).length) return false;
|
|
54
|
-
return keys.every((key) => Object.hasOwn(b, key) && a[key]
|
|
58
|
+
return keys.every((key) => Object.hasOwn(b, key) && sameJson(a[key], b[key]));
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export function replayOverlays(
|
package/src/use-channel.ts
CHANGED
|
@@ -8,8 +8,10 @@ import { installedPage } from './page-store';
|
|
|
8
8
|
import { isServerRender, signalFor } from './reactivity';
|
|
9
9
|
import type { PresenceMember } from './sync-protocol';
|
|
10
10
|
|
|
11
|
-
/** The membership's state, callable, plus its release (Solid: `onCleanup`). */
|
|
11
|
+
/** The membership's state, callable, plus its refusal and its release (Solid: `onCleanup`). */
|
|
12
12
|
export type ChannelAccessor = (() => ChannelState) & {
|
|
13
|
+
/** What the node answered when it refused the membership (`failed`); `undefined` otherwise. */
|
|
14
|
+
error(): unknown;
|
|
13
15
|
release(): void;
|
|
14
16
|
[Symbol.dispose](): void;
|
|
15
17
|
};
|
|
@@ -29,6 +31,7 @@ export function useChannel<K extends string>(
|
|
|
29
31
|
const signal = signalFor('useChannel');
|
|
30
32
|
if (isServerRender()) {
|
|
31
33
|
return Object.assign((): ChannelState => 'joining', {
|
|
34
|
+
error: (): unknown => undefined,
|
|
32
35
|
release: nothing,
|
|
33
36
|
[Symbol.dispose]: nothing,
|
|
34
37
|
});
|
|
@@ -42,11 +45,16 @@ export function useChannel<K extends string>(
|
|
|
42
45
|
version();
|
|
43
46
|
return membership.state();
|
|
44
47
|
};
|
|
48
|
+
// Read through the same version, so a component rendering the refusal re-renders with it.
|
|
49
|
+
const error = (): unknown => {
|
|
50
|
+
version();
|
|
51
|
+
return membership.error();
|
|
52
|
+
};
|
|
45
53
|
const release = (): void => {
|
|
46
54
|
off();
|
|
47
55
|
membership.release();
|
|
48
56
|
};
|
|
49
|
-
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
57
|
+
return Object.assign(read, { error, release, [Symbol.dispose]: release });
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
/** Who is in a channel's room, as the node's presence set says — one entry per member id. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Which records of some types moved, without holding any of them. A whole-type `useRecords` is the
|
|
2
|
+
// way to SHOW every record of a type, and holding the type is its price: none of its rows is
|
|
3
|
+
// evicted while the reader lives. A reader that only wants to know THAT something moved — re-render
|
|
4
|
+
// a server page, flash a counter — pays nothing here: it holds no row and no type.
|
|
5
|
+
|
|
6
|
+
import { pageStore } from './page-store';
|
|
7
|
+
import { isServerRender } from './reactivity';
|
|
8
|
+
|
|
9
|
+
/** Per type, the keys of its records that one store update moved (written, removed or evicted). */
|
|
10
|
+
export type RecordChanges = ReadonlyMap<string, readonly string[]>;
|
|
11
|
+
|
|
12
|
+
/** The listener's undo — call it, `release()` it or `using` it (Solid: `onCleanup`). */
|
|
13
|
+
export type RecordChangesHandle = (() => void) & { release(): void; [Symbol.dispose](): void };
|
|
14
|
+
|
|
15
|
+
const nothing = (): void => undefined;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calls `listener` once per store update that moved at least one record of `types`, with what
|
|
19
|
+
* moved, grouped by type. Holds nothing: a record no other reader holds is evicted as before.
|
|
20
|
+
*/
|
|
21
|
+
export function useRecordChanges(
|
|
22
|
+
types: readonly string[],
|
|
23
|
+
listener: (changed: RecordChanges) => void,
|
|
24
|
+
): RecordChangesHandle {
|
|
25
|
+
if (isServerRender())
|
|
26
|
+
return Object.assign(nothing, { release: nothing, [Symbol.dispose]: nothing });
|
|
27
|
+
const watched = new Set(types);
|
|
28
|
+
const unsubscribe = pageStore('useRecordChanges').subscribe((changed) => {
|
|
29
|
+
const moved = new Map<string, string[]>();
|
|
30
|
+
for (const rk of changed) {
|
|
31
|
+
const at = rk.indexOf(':');
|
|
32
|
+
const type = rk.slice(0, at);
|
|
33
|
+
if (!watched.has(type)) continue;
|
|
34
|
+
const keys = moved.get(type) ?? [];
|
|
35
|
+
keys.push(rk.slice(at + 1));
|
|
36
|
+
moved.set(type, keys);
|
|
37
|
+
}
|
|
38
|
+
if (moved.size > 0) listener(moved);
|
|
39
|
+
});
|
|
40
|
+
let held = true;
|
|
41
|
+
const release = (): void => {
|
|
42
|
+
if (!held) return;
|
|
43
|
+
held = false;
|
|
44
|
+
unsubscribe();
|
|
45
|
+
};
|
|
46
|
+
return Object.assign(release, { release, [Symbol.dispose]: release });
|
|
47
|
+
}
|