@ultimat3/realtime 20.2.1 → 21.0.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.
Files changed (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
@@ -0,0 +1,59 @@
1
+ // The page socket, as four getters: whether it is up, when it redials, and whether a newer build
2
+ // is live. Getters, never a snapshot, so a read inside a tracking scope stays live. Asking opens
3
+ // the socket — this hook IS a question about it.
4
+
5
+ import { pageSocket } from './page-socket';
6
+ import { isServerRender, signalFor } from './reactivity';
7
+
8
+ export interface Connection extends Disposable {
9
+ /** Stop listening to the socket (Solid: `onCleanup`). The socket itself stays up. */
10
+ release(): void;
11
+ readonly offline: boolean;
12
+ readonly online: boolean;
13
+ /** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
14
+ readonly reconnectAt: number | null;
15
+ /** The buildId the server announced, or `null` while this build is current. */
16
+ readonly updateAvailable: string | null;
17
+ }
18
+
19
+ /**
20
+ * A server render is ONLINE: the banner is about this visitor's connectivity, and the request
21
+ * being served is the proof it is up. Answering offline would render "you are offline" into every
22
+ * document and remove it on hydrate.
23
+ */
24
+ const SERVER_RENDER: Connection = Object.freeze({
25
+ release: (): void => undefined,
26
+ [Symbol.dispose]: (): void => undefined,
27
+ offline: false,
28
+ online: true,
29
+ reconnectAt: null,
30
+ updateAvailable: null,
31
+ });
32
+
33
+ export function useConnection(): Connection {
34
+ const signal = signalFor('useConnection');
35
+ if (isServerRender()) return SERVER_RENDER;
36
+ const client = pageSocket('useConnection');
37
+ const [version, setVersion] = signal(0);
38
+ const release = client.onStatus(() => setVersion(version() + 1));
39
+ return {
40
+ release,
41
+ [Symbol.dispose]: release,
42
+ get offline() {
43
+ version();
44
+ return !client.connected;
45
+ },
46
+ get online() {
47
+ version();
48
+ return client.connected;
49
+ },
50
+ get reconnectAt() {
51
+ version();
52
+ return client.reconnectAt();
53
+ },
54
+ get updateAvailable() {
55
+ version();
56
+ return client.appUpdateAvailable();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,214 @@
1
+ // A client write, the one way: the mutator's optimistic twin into the store's OVERLAY (visible to
2
+ // every island before this returns), then its action over HTTP through core's one transport with
3
+ // an idempotency key. The answer's records are adopted before the overlay goes — no flicker — and
4
+ // a refusal takes the overlay back. The socket carries no writes.
5
+
6
+ import type { ConflictPolicy } from '@ultimat3/core/page';
7
+ import {
8
+ actionPath,
9
+ clientTransport,
10
+ isSuperseded,
11
+ isUltimateError,
12
+ uuid,
13
+ } from '@ultimat3/core/page';
14
+ import type { JsonValue } from './json';
15
+ import { type OutboxHandle, peekOutbox } from './outbox-slot';
16
+ import { ServerRenderLiveError } from './page-errors';
17
+ import { type PageWrites, pageRealtime } from './page-store';
18
+ import { isServerRender, signalFor } from './reactivity';
19
+ import { carriedBy } from './record-store';
20
+
21
+ /**
22
+ * What a hook needs from a mutator, and nothing a server holds: the name its action is routed
23
+ * under, the optimistic twin, and the conflict policy. An island declares this beside its
24
+ * component — the `mutator()` VALUE drags its server half into the bundle.
25
+ *
26
+ * `local` is declared with **method syntax** on purpose: TypeScript relates method parameters
27
+ * bivariantly, so a twin typed over its own `tx` and parsed input assigns here with no cast.
28
+ */
29
+ export interface MutatorLike {
30
+ readonly name: string;
31
+ /** Pure: no I/O, no clock, no randomness — it is REPLAYED over every server update. */
32
+ local?(tx: unknown, input: unknown): void;
33
+ /** `@ultimat3/core`'s one row-shaped vocabulary. Default `server-wins`. */
34
+ readonly conflict?: ConflictPolicy;
35
+ }
36
+
37
+ /**
38
+ * A callable mutator with its own in-flight count attached. Resolves with the action's output — or
39
+ * with `undefined` when the network took nothing and the write went to the outbox, overlay kept.
40
+ */
41
+ export type Mutate = ((input: JsonValue) => Promise<unknown>) & {
42
+ /** Calls of this mutator the server has not answered yet. */
43
+ readonly pending: number;
44
+ /** Stop counting for this hook (Solid: `onCleanup`). A call already made still settles. */
45
+ release(): void;
46
+ [Symbol.dispose](): void;
47
+ };
48
+
49
+ export interface MutationQueue extends Disposable {
50
+ /** Optimistic writes the server has not answered yet, across every mutator on the page. */
51
+ readonly pending: number;
52
+ /** Writes the server refused since the page loaded. */
53
+ readonly failed: number;
54
+ /** Stop listening (Solid: `onCleanup`) — a page-wide listener outlives any one component. */
55
+ release(): void;
56
+ }
57
+
58
+ /** How a transport failure failed (`meta.failure`), or `undefined` for any other error. */
59
+ function transportFailure(error: unknown): unknown {
60
+ if (!isUltimateError(error) || error.code !== 'X_CLIENT_TRANSPORT_FAILED') return undefined;
61
+ return error.meta?.['failure'];
62
+ }
63
+
64
+ function notify(writes: PageWrites): void {
65
+ for (const listener of writes.listeners) listener();
66
+ }
67
+
68
+ function count(writes: PageWrites, name: string, delta: number): void {
69
+ const next = (writes.pending.get(name) ?? 0) + delta;
70
+ if (next > 0) writes.pending.set(name, next);
71
+ else writes.pending.delete(name);
72
+ notify(writes);
73
+ }
74
+
75
+ /**
76
+ * One version signal over the page's write counts, per hook: that bundle's reactive handle, plus
77
+ * the release that takes its listener off the page — the counts outlive every component.
78
+ */
79
+ function watch(hook: string, writes: PageWrites | undefined): [() => number, () => void] {
80
+ const [version, setVersion] = signalFor(hook)(0);
81
+ const listener = (): void => setVersion(version() + 1);
82
+ writes?.listeners.add(listener);
83
+ return [version, () => writes?.listeners.delete(listener)];
84
+ }
85
+
86
+ /**
87
+ * After a reload the outbox still holds the writes the network refused, but the store holds only
88
+ * synced truth — the persister never writes an overlay. So the first `useMutation` of a mutator on
89
+ * the page re-applies its twin for each of those writes, in queue order, as the overlay keyed to
90
+ * that write's idempotency key: a replay then settles or rolls it back exactly as a live write.
91
+ */
92
+ function restoreOverlays(mutator: MutatorLike): void {
93
+ const local = mutator.local;
94
+ if (local === undefined) return;
95
+ const page = pageRealtime();
96
+ // The boot opens the outbox; after it, so a queue the previous load left is on disk to read.
97
+ void page.booted.then(async () => {
98
+ const outbox = peekOutbox();
99
+ if (outbox === undefined) return;
100
+ await outbox.ready;
101
+ const store = page.store;
102
+ const shown = new Set(store.pending());
103
+ for (const entry of outbox.pending()) {
104
+ if (entry.name !== mutator.name || shown.has(entry.key)) continue;
105
+ store.push(
106
+ entry.key,
107
+ (tx) => mutator.local?.(tx, entry.input),
108
+ mutator.conflict ?? 'server-wins',
109
+ );
110
+ }
111
+ });
112
+ }
113
+
114
+ /** The page's outbox once the boot has finished opening it; `undefined` on a page with no boot. */
115
+ async function bootedOutbox(page: {
116
+ readonly booted: Promise<void>;
117
+ }): Promise<OutboxHandle | undefined> {
118
+ await page.booted;
119
+ return peekOutbox();
120
+ }
121
+
122
+ export function useMutation(mutator: MutatorLike): Mutate {
123
+ const server = isServerRender();
124
+ const writes = server ? undefined : pageRealtime().writes;
125
+ if (!server) restoreOverlays(mutator);
126
+ const [version, release] = watch('useMutation', writes);
127
+ const call = async (input: JsonValue): Promise<unknown> => {
128
+ if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
129
+ const page = pageRealtime();
130
+ const key = `${mutator.name}:${uuid()}`;
131
+ const local = mutator.local;
132
+ // Called back through the mutator: `local` may be a method, and an unbound one loses `this`.
133
+ if (local !== undefined) {
134
+ page.store.push(key, (tx) => mutator.local?.(tx, input), mutator.conflict ?? 'server-wins');
135
+ }
136
+ count(writes, mutator.name, 1);
137
+ let output: unknown;
138
+ /** The records the answer carried, `type:key` — what the overlay may be settled against. */
139
+ const carried = new Set<string>();
140
+ try {
141
+ output = await clientTransport({
142
+ method: 'POST',
143
+ url: actionPath(mutator.name),
144
+ body: input,
145
+ idempotencyKey: key,
146
+ onEnvelope: (envelope) => carriedBy(envelope, carried),
147
+ });
148
+ } catch (error) {
149
+ const failure = transportFailure(error);
150
+ // No response at all: the write is the outbox's now, under the SAME idempotency key, and its
151
+ // overlay stays on screen until the replay settles or refuses it. Only a page the boot opened
152
+ // an outbox on can promise that; with none (no boot, so nothing on this page persists) the
153
+ // write is refused like any other, never held in a memory queue a reload would silently lose.
154
+ const outbox = failure === 'network' ? await bootedOutbox(page) : undefined;
155
+ if (outbox !== undefined) {
156
+ await outbox.enqueue({ key, name: mutator.name, input });
157
+ return undefined;
158
+ }
159
+ if (failure === 'body') {
160
+ // A 2xx whose body was not JSON: the write may well have landed, so it is neither queued
161
+ // (a replay would be a second attempt) nor taken back — its overlay waits for the next
162
+ // server row it touched. The caller is still told: nothing here can confirm it.
163
+ page.store.awaitServer(key);
164
+ writes.failed += 1;
165
+ throw error;
166
+ }
167
+ page.store.drop(key);
168
+ // A write superseded by a principal change DID land; it is the previous principal's, and
169
+ // nothing about it is this page's failure to report.
170
+ if (!isSuperseded(error)) writes.failed += 1;
171
+ throw error;
172
+ } finally {
173
+ count(writes, mutator.name, -1);
174
+ }
175
+ try {
176
+ // A row the answer did not carry keeps its overlay until the server's row reaches it.
177
+ page.store.settle(key, carried);
178
+ } catch (error) {
179
+ // A custom merge that answered no row: the server's truth stands, and the caller is told.
180
+ page.store.drop(key);
181
+ throw error;
182
+ }
183
+ return output;
184
+ };
185
+ Object.defineProperty(call, 'pending', {
186
+ get: (): number => {
187
+ version();
188
+ return writes?.pending.get(mutator.name) ?? 0;
189
+ },
190
+ });
191
+ Object.assign(call, { release, [Symbol.dispose]: release });
192
+ // `defineProperty` cannot widen a function type, so the assembled shape is asserted once, here.
193
+ return call as Mutate;
194
+ }
195
+
196
+ /** Every write on the page, as two counts. Zero on a server render, where nothing is written. */
197
+ export function useMutationQueue(): MutationQueue {
198
+ const writes = isServerRender() ? undefined : pageRealtime().writes;
199
+ const [version, release] = watch('useMutationQueue', writes);
200
+ return {
201
+ release,
202
+ [Symbol.dispose]: release,
203
+ get pending() {
204
+ version();
205
+ let total = 0;
206
+ for (const n of writes?.pending.values() ?? []) total += n;
207
+ return total;
208
+ },
209
+ get failed() {
210
+ version();
211
+ return writes?.failed ?? 0;
212
+ },
213
+ };
214
+ }
@@ -0,0 +1,255 @@
1
+ // The ONE read hook. Live-ness is the query's, not the hook's: a live ref subscribes over the
2
+ // page socket, any other is one HTTP read through `@ultimat3/query`'s client (and so through
3
+ // core's one transport). Either way a list is an ORDER of keys and the rows are the page store's,
4
+ // so a record updated anywhere re-renders every list showing it without refetching the list.
5
+
6
+ import { type AsyncState, isSuperseded, type RecordEnvelope, type Row } from '@ultimat3/core/page';
7
+ import { queryClientMethodFor } from '@ultimat3/query/client';
8
+ import type { LiveHandle } from './client-contract';
9
+ import type { JsonValue } from './json';
10
+ import { pageSocket } from './page-socket';
11
+ import { pageStore } from './page-store';
12
+ import { isServerRender, signalFor } from './reactivity';
13
+ import { type RecordStore, recordKey } from './record-store';
14
+
15
+ /**
16
+ * What a browser may name a query by: its registered name and two facts the server declaration
17
+ * holds. Never the query VALUE — importing one drags its whole read path into the island
18
+ * (measured 698,801 B for the dummy feed). The type has no server field, which is the rule.
19
+ */
20
+ export interface QueryRef {
21
+ readonly name: string;
22
+ /** `live: true` on the declaration: rows arrive and move over the page socket. */
23
+ readonly live?: boolean;
24
+ /**
25
+ * The record type (entity name) a NON-live read's rows are. Named, the list is the keys of the
26
+ * answer's `records[type]` — store records any write moves; omitted, or answered with no records
27
+ * envelope, the list holds its rows itself and only a refetch moves them. A live read needs none.
28
+ */
29
+ readonly entity?: string;
30
+ }
31
+
32
+ /** A callable `AsyncState` over the list, plus the two things a caller does to it. */
33
+ export type QueryAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
34
+ /** Read again from the first page, keeping the current rows on screen (`refreshing`). */
35
+ refetch(): void;
36
+ /**
37
+ * The next page, appended — from the cursor the server's last page answered with. A no-op on a
38
+ * live read, on a read with no `first`, and once the server said there is no next page.
39
+ */
40
+ more(): void;
41
+ /** Whether the server's last page said another follows. Reactive, like the accessor itself. */
42
+ hasMore(): boolean;
43
+ release(): void;
44
+ [Symbol.dispose](): void;
45
+ };
46
+
47
+ const PENDING: AsyncState<never> = Object.freeze({ status: 'pending' });
48
+ const nothing = (): void => undefined;
49
+
50
+ /**
51
+ * `input` is read ONCE, at call time: there is no reactive runtime here to re-run it, and a
52
+ * silently stale subscription is worse than a new call. A changed input is a new `useQuery`.
53
+ */
54
+ export interface QueryOptions {
55
+ /** Read in pages of this many rows (`query.page`); `more()` appends the next. Non-live only. */
56
+ readonly first?: number;
57
+ }
58
+
59
+ export function useQuery<R extends object = Row>(
60
+ ref: QueryRef,
61
+ input: JsonValue,
62
+ options: QueryOptions = {},
63
+ ): QueryAccessor<R> {
64
+ const signal = signalFor('useQuery');
65
+ if (isServerRender()) {
66
+ return Object.assign((): AsyncState<readonly R[]> => PENDING, {
67
+ refetch: nothing,
68
+ more: nothing,
69
+ hasMore: () => false,
70
+ release: nothing,
71
+ [Symbol.dispose]: nothing,
72
+ });
73
+ }
74
+ const [version, setVersion] = signal(0);
75
+ const bump = (): void => setVersion(version() + 1);
76
+ return ref.live === true
77
+ ? liveAccessor<R>(pageSocket('useQuery').subscribeLive<R>(ref, input), version, bump)
78
+ : readAccessor<R>(pageStore(), ref, input, options, version, bump);
79
+ }
80
+
81
+ function liveAccessor<R extends object>(
82
+ handle: LiveHandle<R>,
83
+ version: () => number,
84
+ bump: () => void,
85
+ ): QueryAccessor<R> {
86
+ // Seen once a snapshot landed — read or not — so a drop after it keeps the rows on screen.
87
+ let seen = handle.state() === 'live';
88
+ const off = handle.onChange(() => {
89
+ if (handle.state() === 'live') seen = true;
90
+ bump();
91
+ });
92
+ const read = (): AsyncState<readonly R[]> => {
93
+ version();
94
+ const state = handle.state();
95
+ if (state === 'failed') return { status: 'failed', error: handle.error() };
96
+ if (state === 'live') {
97
+ seen = true;
98
+ return { status: 'ready', data: handle.rows() };
99
+ }
100
+ // Loading, stale or offline: what was shown stays shown, marked busy — never torn down.
101
+ return seen ? { status: 'refreshing', data: handle.rows() } : PENDING;
102
+ };
103
+ const release = (): void => {
104
+ off();
105
+ handle.unsubscribe();
106
+ };
107
+ return Object.assign(read, {
108
+ refetch: nothing,
109
+ more: nothing,
110
+ hasMore: () => false,
111
+ release,
112
+ [Symbol.dispose]: release,
113
+ });
114
+ }
115
+
116
+ function readAccessor<R extends object>(
117
+ store: RecordStore,
118
+ ref: QueryRef,
119
+ input: JsonValue,
120
+ options: QueryOptions,
121
+ version: () => number,
122
+ bump: () => void,
123
+ ): QueryAccessor<R> {
124
+ const type = ref.entity;
125
+ const method = queryClientMethodFor(ref.name, { baseUrl: '' });
126
+ let state: AsyncState<readonly Row[]> = PENDING;
127
+ /** Record keys in answer order when the rows are records; the rows themselves when not. */
128
+ let keys: readonly string[] = [];
129
+ let own: readonly Row[] = [];
130
+ let released = false;
131
+ let generation = 0;
132
+ /** The cursor the server's last page answered with; `null` = no next page (or not paged). */
133
+ let after: string | null = null;
134
+ const off =
135
+ type === undefined
136
+ ? nothing
137
+ : store.subscribe((changed) => {
138
+ if (keys.some((key) => changed.has(recordKey(type, key)))) bump();
139
+ });
140
+
141
+ const rows = (): readonly Row[] => {
142
+ if (type === undefined || keys.length === 0) return own;
143
+ const out: Row[] = [];
144
+ for (const key of keys) {
145
+ const row = store.peek(type, key);
146
+ if (row !== undefined) out.push(row);
147
+ }
148
+ return out;
149
+ };
150
+
151
+ const hold = (next: readonly string[]): void => {
152
+ if (type === undefined) return;
153
+ store.batch(() => {
154
+ for (const key of next) store.retain(type, key);
155
+ for (const key of keys) store.release(type, key);
156
+ });
157
+ };
158
+
159
+ /**
160
+ * One answer: its rows, and — when the server sent the records envelope — the KEYS of this
161
+ * read's records in answer order (`records[type]`, which `rowsOf` fills first-seen = data order).
162
+ * The browser never derives a key: an answer with no records envelope holds its rows itself.
163
+ */
164
+ const fetch = (append: boolean): Promise<{ rows: readonly Row[]; keys: string[] | null }> => {
165
+ let keysOf: string[] | null = null;
166
+ const onEnvelope = (envelope: RecordEnvelope): void => {
167
+ const records = type === undefined ? undefined : envelope.records?.[type];
168
+ keysOf = records === undefined ? null : Object.keys(records);
169
+ };
170
+ const answered = (rows: readonly Row[]) => ({ rows, keys: keysOf });
171
+ if (options.first === undefined) {
172
+ return (method(input, { onEnvelope }) as Promise<readonly Row[]>).then(answered);
173
+ }
174
+ const controls =
175
+ append && after !== null ? { first: options.first, after } : { first: options.first };
176
+ return method.page(input, controls, { onEnvelope }).then((page) => {
177
+ after = page.hasNextPage ? page.endCursor : null;
178
+ return answered(page.rows as readonly Row[]);
179
+ });
180
+ };
181
+
182
+ const load = (append = false): void => {
183
+ const mine = ++generation;
184
+ if (state.status === 'ready') state = { status: 'refreshing', data: rows() };
185
+ bump();
186
+ fetch(append).then(
187
+ (answer) => {
188
+ if (released || mine !== generation) return;
189
+ if (type === undefined || answer.keys === null) {
190
+ // Not records — no type named, or no envelope: the list holds its own rows.
191
+ own = append ? [...own, ...answer.rows] : answer.rows;
192
+ if (!append) {
193
+ hold([]);
194
+ keys = [];
195
+ }
196
+ } else {
197
+ // The transport adopted the envelope's records before this ran; the window is their keys.
198
+ const answered = answer.keys;
199
+ const next = append
200
+ ? [...keys, ...answered.filter((key) => !keys.includes(key))]
201
+ : answered;
202
+ hold(next);
203
+ keys = next;
204
+ if (!append) own = [];
205
+ }
206
+ state = { status: 'ready', data: [] };
207
+ bump();
208
+ },
209
+ (error: unknown) => {
210
+ if (released || mine !== generation) return;
211
+ // The page changed principal under this read: its answer was the previous one's, and the
212
+ // store it would have landed in is already empty. Read again, as the new principal.
213
+ if (isSuperseded(error)) {
214
+ load();
215
+ return;
216
+ }
217
+ state = { status: 'failed', error };
218
+ bump();
219
+ },
220
+ );
221
+ };
222
+
223
+ load();
224
+ const read = (): AsyncState<readonly R[]> => {
225
+ version();
226
+ if (state.status === 'pending' || state.status === 'failed') return state;
227
+ // Rows are typed by the caller's query; the store holds them as JSON rows.
228
+ const data = rows() as readonly R[];
229
+ return state.status === 'refreshing'
230
+ ? { status: 'refreshing', data }
231
+ : { status: 'ready', data };
232
+ };
233
+ const release = (): void => {
234
+ if (released) return;
235
+ released = true;
236
+ off();
237
+ hold([]);
238
+ keys = [];
239
+ };
240
+ return Object.assign(read, {
241
+ refetch: () => {
242
+ after = null;
243
+ load();
244
+ },
245
+ more: () => {
246
+ if (after !== null && state.status === 'ready') load(true);
247
+ },
248
+ hasMore: () => {
249
+ version();
250
+ return after !== null;
251
+ },
252
+ release,
253
+ [Symbol.dispose]: release,
254
+ });
255
+ }
@@ -0,0 +1,121 @@
1
+ // One record, by type and key, out of the page's store — the same object every island showing it
2
+ // holds, moved once by whichever write lands first (an HTTP answer, a socket frame, an optimistic
3
+ // twin). Imports no socket: an island that only reads records ships none of the lifecycle.
4
+
5
+ import type { AsyncState, Row } from '@ultimat3/core/page';
6
+ import { pageStore } from './page-store';
7
+ import { isServerRender, signalFor } from './reactivity';
8
+ import { recordKey } from './record-store';
9
+
10
+ /**
11
+ * A callable `AsyncState`: `pending` until the record first arrives, `ready` from then on —
12
+ * `ready` with `undefined` once the server removed it, so a deleted record is a settled answer
13
+ * and never a skeleton forever. `release()` lets go; the caller owns it (Solid: `onCleanup`).
14
+ */
15
+ export type RecordAccessor<R extends object = Row> = (() => AsyncState<R | undefined>) & {
16
+ release(): void;
17
+ [Symbol.dispose](): void;
18
+ };
19
+
20
+ const PENDING: AsyncState<never> = Object.freeze({ status: 'pending' });
21
+
22
+ /** Nothing was held, so nothing is released — and a teardown never fails a render. */
23
+ const releaseNothing = (): void => undefined;
24
+
25
+ export function useRecord<R extends object = Row>(type: string, key: string): RecordAccessor<R> {
26
+ const signal = signalFor('useRecord');
27
+ if (isServerRender()) {
28
+ // The record arrives in a browser this render does not have: the page's own loading branch.
29
+ return Object.assign((): AsyncState<R | undefined> => PENDING, {
30
+ release: releaseNothing,
31
+ [Symbol.dispose]: releaseNothing,
32
+ });
33
+ }
34
+ const store = pageStore();
35
+ const [version, setVersion] = signal(0);
36
+ const target = recordKey(type, key);
37
+ store.retain(type, key);
38
+ // Seen once it has ever been there — read or not — so a removal after it is a settled answer.
39
+ let seen = store.peek(type, key) !== undefined;
40
+ const unsubscribe = store.subscribe((changed) => {
41
+ if (!changed.has(target)) return;
42
+ if (store.peek(type, key) !== undefined) seen = true;
43
+ setVersion(version() + 1);
44
+ });
45
+ const read = (): AsyncState<R | undefined> => {
46
+ version();
47
+ // The store holds JSON rows; the caller names the entity row type it reads them as.
48
+ const row = store.peek(type, key) as R | undefined;
49
+ if (row !== undefined) seen = true;
50
+ return row === undefined && !seen ? PENDING : { status: 'ready', data: row };
51
+ };
52
+ let held = true;
53
+ const release = (): void => {
54
+ if (!held) return;
55
+ held = false;
56
+ unsubscribe();
57
+ store.release(type, key);
58
+ };
59
+ return Object.assign(read, { release, [Symbol.dispose]: release });
60
+ }
61
+
62
+ /** A callable `AsyncState` over several records, in the order their keys were given. */
63
+ export type RecordsAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
64
+ release(): void;
65
+ [Symbol.dispose](): void;
66
+ };
67
+
68
+ /**
69
+ * Several records of one type, by key — the same store objects `useRecord` hands out. `pending`
70
+ * until the first of them arrives; then `ready` with those present, in key order: a record the
71
+ * server removed simply drops out, and a list is never shown as its skeleton again.
72
+ */
73
+ export function useRecords<R extends object = Row>(
74
+ type: string,
75
+ keys: readonly string[],
76
+ ): RecordsAccessor<R> {
77
+ const signal = signalFor('useRecords');
78
+ if (isServerRender()) {
79
+ return Object.assign((): AsyncState<readonly R[]> => PENDING, {
80
+ release: releaseNothing,
81
+ [Symbol.dispose]: releaseNothing,
82
+ });
83
+ }
84
+ const store = pageStore();
85
+ const [version, setVersion] = signal(0);
86
+ const targets = new Set(keys.map((key) => recordKey(type, key)));
87
+ const present = (): R[] => {
88
+ const out: R[] = [];
89
+ // The store holds JSON rows; the caller names the entity row type it reads them as.
90
+ for (const key of keys) {
91
+ const row = store.peek(type, key) as R | undefined;
92
+ if (row !== undefined) out.push(row);
93
+ }
94
+ return out;
95
+ };
96
+ store.batch(() => {
97
+ for (const key of keys) store.retain(type, key);
98
+ });
99
+ let seen = present().length > 0;
100
+ const unsubscribe = store.subscribe((changed) => {
101
+ if (![...changed].some((key) => targets.has(key))) return;
102
+ if (present().length > 0) seen = true;
103
+ setVersion(version() + 1);
104
+ });
105
+ const read = (): AsyncState<readonly R[]> => {
106
+ version();
107
+ const rows = present();
108
+ if (rows.length > 0) seen = true;
109
+ return !seen ? PENDING : { status: 'ready', data: rows };
110
+ };
111
+ let held = true;
112
+ const release = (): void => {
113
+ if (!held) return;
114
+ held = false;
115
+ unsubscribe();
116
+ store.batch(() => {
117
+ for (const key of keys) store.release(type, key);
118
+ });
119
+ };
120
+ return Object.assign(read, { release, [Symbol.dispose]: release });
121
+ }