@ultimat3/realtime 20.2.1 → 22.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.
- package/CLAUDE.md +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/client-mutations.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
// The outbound mutation path: the optimistic twin, the durable queue entry, and the sender the
|
|
2
|
-
// drain hands each frame to. One file because those are the three places a single intent is
|
|
3
|
-
// recorded, and an intent that reaches two of them is the divergence tier 3 exists to prevent.
|
|
4
|
-
|
|
5
|
-
import { uuid } from '@ultimat3/core';
|
|
6
|
-
import type { ClientSocket, MutatorRef } from './client-contract';
|
|
7
|
-
import { TransportUnavailableError } from './errors';
|
|
8
|
-
import type { JsonValue } from './json';
|
|
9
|
-
import type { LocalStore, TableMap } from './local-store';
|
|
10
|
-
import { type MutationSender, mutateFrame, type OfflineQueue } from './offline-queue';
|
|
11
|
-
import type { RebaseLog } from './rebase';
|
|
12
|
-
import { encode, type Frame } from './sync-protocol';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Queued bytes past which the drain stops rather than adds. The same number the node uses at its
|
|
16
|
-
* end of the socket (`DEFAULT_MAX_BUFFERED_BYTES`), and deliberately NOT imported from it: this is
|
|
17
|
-
* browser code, and `socket.ts` is the node's socket registry, its metrics and its close codes —
|
|
18
|
-
* one import pulls the whole server half into the tab's bundle to read an integer. The node's two
|
|
19
|
-
* spellings were merged because they configure one buffer on one side; these are two sides.
|
|
20
|
-
*/
|
|
21
|
-
export const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
22
|
-
|
|
23
|
-
/** Everything the mutation path touches. Narrow on purpose, exactly like `ClientFrameTarget`. */
|
|
24
|
-
export interface MutationDeps<T extends TableMap = TableMap> {
|
|
25
|
-
readonly store: LocalStore<T> | undefined;
|
|
26
|
-
readonly queue: OfflineQueue | undefined;
|
|
27
|
-
readonly log: RebaseLog<T> | undefined;
|
|
28
|
-
readonly now: () => number;
|
|
29
|
-
/** Read per send, never captured: the socket a drain started on may already be gone. */
|
|
30
|
-
socket(): ClientSocket | null;
|
|
31
|
-
send(frame: Frame): void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Record one intent everywhere it has to be recorded: the local store (so the UI moves now), the
|
|
36
|
-
* rebase log (so it can be taken back) and the durable queue (so it survives the tab). Nothing is
|
|
37
|
-
* sent here — `drain` is the only thing that puts a mutation on a socket, and with no queue at all
|
|
38
|
-
* (tier 2) the frame goes straight out because there is nothing to drain it from later.
|
|
39
|
-
*/
|
|
40
|
-
export async function recordMutation<T extends TableMap>(
|
|
41
|
-
deps: MutationDeps<T>,
|
|
42
|
-
mutator: MutatorRef<T>,
|
|
43
|
-
input: JsonValue,
|
|
44
|
-
key?: string,
|
|
45
|
-
): Promise<void> {
|
|
46
|
-
const idempotencyKey = key ?? `${mutator.name}:${uuid()}`;
|
|
47
|
-
const { store, queue } = deps;
|
|
48
|
-
const local = mutator.local;
|
|
49
|
-
const existing = queue?.find(idempotencyKey);
|
|
50
|
-
const queued = await queue?.enqueue({
|
|
51
|
-
key: idempotencyKey,
|
|
52
|
-
name: mutator.name,
|
|
53
|
-
input,
|
|
54
|
-
at: deps.now(),
|
|
55
|
-
});
|
|
56
|
-
// Identity, not a second copy of the queue's collapse rule: `enqueue` hands back the SAME entry
|
|
57
|
-
// when it collapses and a new one when it does not. A repeated key is ONE intent whose twin is
|
|
58
|
-
// already applied — applying it again double-counts the write (a like becomes two) and replaces
|
|
59
|
-
// the log entry a rollback would have undone to the pre-mutation row.
|
|
60
|
-
const collapsed = existing !== undefined && queued === existing;
|
|
61
|
-
if (store && local && !collapsed) {
|
|
62
|
-
store.apply(idempotencyKey, (tx) => local(tx, input));
|
|
63
|
-
deps.log?.record({
|
|
64
|
-
key: idempotencyKey,
|
|
65
|
-
seq: queued?.seq ?? 0,
|
|
66
|
-
entity: mutator.entity ?? mutator.name,
|
|
67
|
-
strategy: mutator.conflict ?? 'server-wins',
|
|
68
|
-
apply: (tx) => local(tx, input),
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
if (queue) return;
|
|
72
|
-
deps.send(
|
|
73
|
-
mutateFrame({
|
|
74
|
-
key: idempotencyKey,
|
|
75
|
-
seq: 0,
|
|
76
|
-
name: mutator.name,
|
|
77
|
-
input,
|
|
78
|
-
enqueuedAt: deps.now(),
|
|
79
|
-
attempts: 0,
|
|
80
|
-
status: 'pending',
|
|
81
|
-
error: null,
|
|
82
|
-
}),
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The queue's sender. Throwing is how a sender declines: the queue keeps that mutation pending,
|
|
88
|
-
* stops the pass rather than reordering the ones behind it, and the next drain resumes there.
|
|
89
|
-
*
|
|
90
|
-
* Backpressure is a decline and not a failure — the frames already queued in the tab are ones the
|
|
91
|
-
* socket has not managed to write, so adding to them is how a client sends a burst it will never
|
|
92
|
-
* see acknowledged. A socket that does not report `bufferedAmount` is treated as never backed up.
|
|
93
|
-
*/
|
|
94
|
-
export function mutationSender<T extends TableMap>(deps: MutationDeps<T>): MutationSender {
|
|
95
|
-
return async (mutation) => {
|
|
96
|
-
const socket = deps.socket();
|
|
97
|
-
if (!socket) {
|
|
98
|
-
throw new TransportUnavailableError({
|
|
99
|
-
transport: 'websocket',
|
|
100
|
-
reason: 'the socket went away before this mutation reached it',
|
|
101
|
-
fix: 'it stays queued: await useMutationQueue().drain() once useConnection().online',
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
const buffered = socket.bufferedAmount ?? 0;
|
|
105
|
-
if (buffered > MAX_BUFFERED_BYTES) {
|
|
106
|
-
throw new TransportUnavailableError({
|
|
107
|
-
transport: 'websocket',
|
|
108
|
-
reason: `${buffered} bytes are already queued on this socket, over the ${MAX_BUFFERED_BYTES} ceiling`,
|
|
109
|
-
fix: 'it stays queued: await useMutationQueue().drain() once the socket has caught up',
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
socket.send(encode(mutateFrame(mutation)));
|
|
113
|
-
};
|
|
114
|
-
}
|
package/src/client-topics.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// The client's channel book: which handlers hold which topic, and the one frame that announces a
|
|
2
|
-
// membership. Split out of `client.ts` because the announcement has two callers that must never
|
|
3
|
-
// disagree — `subscribe()` and the reconnect replay — and one of them was missing.
|
|
4
|
-
|
|
5
|
-
import type { Topic } from './channel';
|
|
6
|
-
import type { JsonObject } from './json';
|
|
7
|
-
import { PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
|
|
8
|
-
|
|
9
|
-
export type TopicHandler = (message: JsonObject) => void;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The membership frame. `sid` is the topic itself: a channel subscription is identified by what it
|
|
13
|
-
* is subscribed to, so re-sending it after a reconnect re-establishes the same membership rather
|
|
14
|
-
* than a second one — and on the node, sending it again IS the presence heartbeat.
|
|
15
|
-
*/
|
|
16
|
-
export function topicSubscribeFrame(name: string, op: 'add' | 'drop'): SubscribeFrame {
|
|
17
|
-
return {
|
|
18
|
-
type: 'subscribe',
|
|
19
|
-
v: PROTOCOL_VERSION,
|
|
20
|
-
op,
|
|
21
|
-
sid: name,
|
|
22
|
-
target: { kind: 'topic', topic: name },
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Topic -> the handlers holding it. One entry per topic, however many components subscribed. */
|
|
27
|
-
export class TopicBook {
|
|
28
|
-
readonly #topics = new Map<string, Set<TopicHandler>>();
|
|
29
|
-
|
|
30
|
-
add(name: Topic, handler: TopicHandler): void {
|
|
31
|
-
const handlers = this.#topics.get(name) ?? new Set<TopicHandler>();
|
|
32
|
-
handlers.add(handler);
|
|
33
|
-
this.#topics.set(name, handlers);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** True when that was the last holder, so the caller is the one that sends the drop frame. */
|
|
37
|
-
remove(name: Topic, handler: TopicHandler): boolean {
|
|
38
|
-
const handlers = this.#topics.get(name);
|
|
39
|
-
if (!handlers) return false;
|
|
40
|
-
handlers.delete(handler);
|
|
41
|
-
if (handlers.size > 0) return false;
|
|
42
|
-
this.#topics.delete(name);
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
handlers(name: string): ReadonlySet<TopicHandler> | undefined {
|
|
47
|
-
return this.#topics.get(name);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Every membership this client still holds — what a reconnect has to re-announce. */
|
|
51
|
-
names(): readonly string[] {
|
|
52
|
-
return [...this.#topics.keys()];
|
|
53
|
-
}
|
|
54
|
-
}
|
package/src/hooks.ts
DELETED
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
// The four calls a component makes: one live query, one connection view, one mutator call, one
|
|
2
|
-
// queue view. Nothing here is a reactive runtime — realtime never imports solid-js, so every
|
|
3
|
-
// accessor is a closure over the `SignalFactory` the registered `LiveClient` was built with, and
|
|
4
|
-
// every hook resolves that client through one ambient seam rather than a context per surface.
|
|
5
|
-
|
|
6
|
-
import type { LiveClientLike, LiveHandle, LiveQueryRef, MutatorRef } from './client';
|
|
7
|
-
import { LiveClientMissingError } from './errors';
|
|
8
|
-
import type { JsonValue, Row } from './json';
|
|
9
|
-
import type { LocalTx } from './local-store';
|
|
10
|
-
import type { ConflictStrategy } from './rebase';
|
|
11
|
-
import { serverRenderLiveClient } from './server-render-client';
|
|
12
|
-
|
|
13
|
-
/** What `setLiveClient` holds. The version signal is the queue's only reactive handle — see below. */
|
|
14
|
-
interface Registered {
|
|
15
|
-
readonly client: LiveClientLike;
|
|
16
|
-
/** Read to subscribe, bumped to invalidate: `OfflineQueue` stores plain arrays, not signals. */
|
|
17
|
-
readonly version: () => number;
|
|
18
|
-
readonly bump: () => void;
|
|
19
|
-
/** Drops this registration's queue listener. The client outlives the registration. */
|
|
20
|
-
readonly release: () => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
let registered: Registered | null = null;
|
|
24
|
-
|
|
25
|
-
/** Register once, in the app entry, before the first render. One app, one socket, one client. */
|
|
26
|
-
export function setLiveClient(client: LiveClientLike): void {
|
|
27
|
-
// The previous registration's listener goes with it. The client outlives `setLiveClient` — a hot
|
|
28
|
-
// reload, a test's next case, an app that re-registers after signing in — so a discarded
|
|
29
|
-
// unsubscribe is a listener nothing can reach, bumping a signal nothing renders, once per
|
|
30
|
-
// registration this process ever made.
|
|
31
|
-
registered?.release();
|
|
32
|
-
const [version, setVersion] = client.signal<number>(0);
|
|
33
|
-
const bump = (): void => {
|
|
34
|
-
setVersion(version() + 1);
|
|
35
|
-
};
|
|
36
|
-
// Closes the gap a direct call can't: a reconnect drains automatically inside `connect()`, and
|
|
37
|
-
// an ack/fail frame arrives asynchronously inside `#onFrame` — neither is awaited by any hook, so
|
|
38
|
-
// this is the only path that reaches them. The direct `bump()` calls below stay too: they fire at
|
|
39
|
-
// the earliest possible moment for the call that made them, and a redundant bump is harmless.
|
|
40
|
-
const release = client.onQueueChange(bump);
|
|
41
|
-
registered = { client, version, bump, release };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** For tests: drop the registration so cases stay independent. */
|
|
45
|
-
export function clearLiveClient(): void {
|
|
46
|
-
registered?.release();
|
|
47
|
-
registered = null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function hasLiveClient(): boolean {
|
|
51
|
-
return registered !== null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A DOM is the whole of the question — the same probe and the same rule `@ultimat3/ui`'s `solid()`
|
|
56
|
-
* follows, and deliberately the same words. With a DOM, a hook reaching for a client nobody
|
|
57
|
-
* registered is a real bug: the app entry forgot `setLiveClient`, and every live query on the page
|
|
58
|
-
* is dead. Without one there is no socket a client could have been registered FOR — that is a
|
|
59
|
-
* server render, and `serverRenderLiveClient()` is an honest account of it rather than a
|
|
60
|
-
* degradation of a working path. Never widen this to "no client, never throw": that is the silent
|
|
61
|
-
* feed-that-never-loads the split exists to prevent.
|
|
62
|
-
*/
|
|
63
|
-
function hasDom(): boolean {
|
|
64
|
-
return typeof document !== 'undefined' && typeof window !== 'undefined';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The server render's registration, built once. Deliberately NOT written to `registered`:
|
|
69
|
-
* `hasLiveClient()` must keep answering `false` on the server, because that is the guard a
|
|
70
|
-
* component with a static fallback already uses to decide it is being server-rendered
|
|
71
|
-
* (`examples/dummy`'s `update-banner.tsx`).
|
|
72
|
-
*/
|
|
73
|
-
let serverSide: Registered | null = null;
|
|
74
|
-
|
|
75
|
-
function serverRegistration(): Registered {
|
|
76
|
-
if (serverSide !== null) return serverSide;
|
|
77
|
-
const client = serverRenderLiveClient();
|
|
78
|
-
// The version signal never moves, and nothing on the server can move it: one pass, no queue,
|
|
79
|
-
// no ack — so `bump` and `release` are the no-ops that fact makes them.
|
|
80
|
-
const [version] = client.signal<number>(0);
|
|
81
|
-
serverSide = { client, version, bump: () => undefined, release: () => undefined };
|
|
82
|
-
return serverSide;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function live(hook: string): Registered {
|
|
86
|
-
if (registered !== null) return registered;
|
|
87
|
-
if (hasDom()) throw new LiveClientMissingError({ hook });
|
|
88
|
-
return serverRegistration();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---- useLive ------------------------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
/** The query's input, or a thunk returning it. The thunk is read once — see `useLive`. */
|
|
94
|
-
export type LiveInput = JsonValue | (() => JsonValue);
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* A callable result set: `feed()` are the rows, `feed.state()` / `feed.cursor()` /
|
|
98
|
-
* `feed.unsubscribe()` are the rest of the `LiveHandle` hanging off it. It is also `Disposable`
|
|
99
|
-
* (inherited from `LiveHandle`), so `using feed = useLive(...)` unsubscribes on scope exit.
|
|
100
|
-
*
|
|
101
|
-
* `R` is only constrained to `object`: on the wire every row is a `Row`, but a hook bound to a
|
|
102
|
-
* declared query (`query-hook.ts`) answers in that query's own row type, which is whatever its
|
|
103
|
-
* `sql` returns. The three members hanging off the accessor are the same for every `R`.
|
|
104
|
-
*/
|
|
105
|
-
export type LiveRows<R extends object = Row> = (() => readonly R[]) & Omit<LiveHandle, 'rows'>;
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Subscribe to a live query. `query` is anything carrying a `name`, which a `@ultimat3/query`
|
|
109
|
-
* `Query` satisfies structurally — realtime is tier 3, so it names the shape rather than importing
|
|
110
|
-
* the package sideways.
|
|
111
|
-
*
|
|
112
|
-
* A thunk `input` is read **once**, at subscribe time: tier 3 has no reactive runtime of its own,
|
|
113
|
-
* so nothing re-runs it when its dependencies change. Changing input means a new subscription.
|
|
114
|
-
* The caller owns `unsubscribe` — nothing here disposes on unmount, because nothing here knows
|
|
115
|
-
* what a mount is. `using` works too, when the caller does have a scope to hang it on.
|
|
116
|
-
*/
|
|
117
|
-
export function useLive<R extends Row = Row>(query: LiveQueryRef, input: LiveInput): LiveRows<R> {
|
|
118
|
-
const handle = live('useLive').client.useLive<R>(
|
|
119
|
-
query,
|
|
120
|
-
typeof input === 'function' ? input() : input,
|
|
121
|
-
);
|
|
122
|
-
return Object.assign((): readonly R[] => handle.rows(), {
|
|
123
|
-
state: handle.state,
|
|
124
|
-
cursor: handle.cursor,
|
|
125
|
-
unsubscribe: handle.unsubscribe,
|
|
126
|
-
[Symbol.dispose]: handle[Symbol.dispose],
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ---- useConnection ------------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
export interface Connection {
|
|
133
|
-
readonly offline: boolean;
|
|
134
|
-
readonly online: boolean;
|
|
135
|
-
/** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
|
|
136
|
-
readonly reconnectAt: number | null;
|
|
137
|
-
/** The buildId the server announced, or `null` while this build is current. */
|
|
138
|
-
readonly updateAvailable: string | null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* The socket, as four booleans and two values. Every member is a **getter**, so a read inside a
|
|
143
|
-
* tracking scope reaches the underlying signal; a snapshot object would freeze the answer at the
|
|
144
|
-
* moment the component rendered and never say "offline" again.
|
|
145
|
-
*/
|
|
146
|
-
export function useConnection(): Connection {
|
|
147
|
-
const client = live('useConnection').client;
|
|
148
|
-
return {
|
|
149
|
-
get offline() {
|
|
150
|
-
return !client.connected;
|
|
151
|
-
},
|
|
152
|
-
get online() {
|
|
153
|
-
return client.connected;
|
|
154
|
-
},
|
|
155
|
-
get reconnectAt() {
|
|
156
|
-
return client.reconnectAt();
|
|
157
|
-
},
|
|
158
|
-
get updateAvailable() {
|
|
159
|
-
return client.appUpdateAvailable();
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---- useMutation --------------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Both spellings of a conflict strategy: realtime's `custom()` (`{ kind: 'custom' }`, merging rows)
|
|
168
|
-
* and `@ultimat3/action`'s (`{ strategy: 'custom' }`, merging parsed outputs).
|
|
169
|
-
*/
|
|
170
|
-
export type ConflictLike = ConflictStrategy | { readonly strategy: 'custom' };
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* What a hook needs from a mutator: the name it queues under, and the optimistic twin. A
|
|
174
|
-
* `@ultimat3/action` `Mutator` satisfies it structurally.
|
|
175
|
-
*
|
|
176
|
-
* `local` is declared with **method syntax** on purpose. TypeScript relates method parameters
|
|
177
|
-
* bivariantly, so a `Mutator` whose `local` takes its own parsed input and its own `tx` assigns
|
|
178
|
-
* here with no cast at the call site; written as a function-typed property, `strictFunctionTypes`
|
|
179
|
-
* would reject exactly that mutator. The parameters are `unknown` because this layer never reads
|
|
180
|
-
* them — it binds them and hands the closure to the client.
|
|
181
|
-
*/
|
|
182
|
-
export interface MutatorLike {
|
|
183
|
-
readonly name: string;
|
|
184
|
-
local?(tx: unknown, input: unknown): void;
|
|
185
|
-
readonly entity?: string;
|
|
186
|
-
readonly conflict?: ConflictLike;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** A callable mutator with its own queue depth attached. */
|
|
190
|
-
export type Mutate = ((input: JsonValue) => Promise<void>) & {
|
|
191
|
-
/** Queued mutations of this mutator. Always `0` at tier 2, where nothing is queued. */
|
|
192
|
-
readonly pending: number;
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Call a mutator: optimistic twin, rebase entry, durable queue, drain — all of it inside
|
|
197
|
-
* `LiveClient.mutate`. `pending` is a getter, so it stays reactive for as long as the component
|
|
198
|
-
* reads it; it refreshes on every call routed through this hook and on `useMutationQueue().drain`.
|
|
199
|
-
*/
|
|
200
|
-
export function useMutation(mutator: MutatorLike): Mutate {
|
|
201
|
-
const state = live('useMutation');
|
|
202
|
-
const ref = mutatorRef(mutator);
|
|
203
|
-
const call = async (input: JsonValue): Promise<void> => {
|
|
204
|
-
await state.client.mutate(ref, input);
|
|
205
|
-
state.bump();
|
|
206
|
-
};
|
|
207
|
-
Object.defineProperty(call, 'pending', {
|
|
208
|
-
get: (): number => {
|
|
209
|
-
state.version();
|
|
210
|
-
const queue = state.client.queue;
|
|
211
|
-
return queue === undefined
|
|
212
|
-
? 0
|
|
213
|
-
: queue.pending().filter((mutation) => mutation.name === mutator.name).length;
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
// `defineProperty` cannot widen a function type, so the assembled shape is asserted once, here.
|
|
217
|
-
return call as Mutate;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ---- useMutationQueue ---------------------------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
export interface MutationQueue {
|
|
223
|
-
/** Queued and not yet acknowledged, across every mutator. */
|
|
224
|
-
readonly pending: number;
|
|
225
|
-
/** Terminally failed — a policy denial or a validation error, kept for the UI, never retried. */
|
|
226
|
-
readonly failed: number;
|
|
227
|
-
drain(): Promise<void>;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** The whole durable queue, as two counts and the one command that empties it. */
|
|
231
|
-
export function useMutationQueue(): MutationQueue {
|
|
232
|
-
const state = live('useMutationQueue');
|
|
233
|
-
return {
|
|
234
|
-
get pending() {
|
|
235
|
-
state.version();
|
|
236
|
-
return state.client.queue?.pending().length ?? 0;
|
|
237
|
-
},
|
|
238
|
-
get failed() {
|
|
239
|
-
state.version();
|
|
240
|
-
const all = state.client.queue?.all() ?? [];
|
|
241
|
-
return all.filter((mutation) => mutation.status === 'failed').length;
|
|
242
|
-
},
|
|
243
|
-
drain: async () => {
|
|
244
|
-
await state.client.drain();
|
|
245
|
-
state.bump();
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** The hook's mutator, as the client's. Only the twin needs binding; the rest is carried through. */
|
|
251
|
-
function mutatorRef(mutator: MutatorLike): MutatorRef {
|
|
252
|
-
const conflict = replayable(mutator.conflict);
|
|
253
|
-
return {
|
|
254
|
-
name: mutator.name,
|
|
255
|
-
// Called back through the mutator rather than through a hoisted reference: `local` may be
|
|
256
|
-
// written as a method, and an unbound method loses the receiver its body could read.
|
|
257
|
-
...(mutator.local === undefined
|
|
258
|
-
? {}
|
|
259
|
-
: {
|
|
260
|
-
local: (tx: LocalTx, input: JsonValue) => {
|
|
261
|
-
mutator.local?.(tx, input);
|
|
262
|
-
},
|
|
263
|
-
}),
|
|
264
|
-
...(mutator.entity === undefined ? {} : { entity: mutator.entity }),
|
|
265
|
-
...(conflict === undefined ? {} : { conflict }),
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* `@ultimat3/action`'s `custom(merge)` merges parsed *outputs*; realtime's merges *rows*. Only the
|
|
271
|
-
* second is a `CustomMerge` `reconcile` can replay, so the other spelling is dropped rather than
|
|
272
|
-
* handed to a rebase that would call it with the wrong argument — the log then takes its default.
|
|
273
|
-
*/
|
|
274
|
-
function replayable(conflict: ConflictLike | undefined): ConflictStrategy | undefined {
|
|
275
|
-
if (conflict === undefined || typeof conflict === 'string') return conflict;
|
|
276
|
-
return 'kind' in conflict ? conflict : undefined;
|
|
277
|
-
}
|
package/src/identity-map.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
// One row VALUE per `(scope, id)` for the whole client — the identity map the thesis takes from
|
|
2
|
-
// Ember Data. Two components holding two copies of one row is the bug it makes unrepresentable:
|
|
3
|
-
// a live query's window and the tier-3 local store are both projections over this one map, so a
|
|
4
|
-
// write through either is the same row for both. Membership and order live in the projections.
|
|
5
|
-
|
|
6
|
-
import type { JsonObject, JsonValue, Row } from './json';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The entity's table — the same name `ChangeEvent.entity`, `tx.<table>` and a mutator's `entity`
|
|
10
|
-
* already use, which is what makes the live path and the local store address one row identically.
|
|
11
|
-
* A subscription whose entity the server did not name gets a private `?query:<name>` scope: no
|
|
12
|
-
* sharing is worse than sharing two different entities that happen to spell one id the same way.
|
|
13
|
-
*/
|
|
14
|
-
export type RowScope = string;
|
|
15
|
-
|
|
16
|
-
/** `scope`+NUL+`id`. NUL cannot occur in an entity name, so the join is unambiguous. */
|
|
17
|
-
export type RowKey = string;
|
|
18
|
-
|
|
19
|
-
export function rowKey(scope: RowScope, id: string): RowKey {
|
|
20
|
-
return `${scope}\u0000${id}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The scope a subscription uses until the server names its entity. `?` starts no entity name. */
|
|
24
|
-
export function privateScope(queryName: string): RowScope {
|
|
25
|
-
return `?query:${queryName}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export type IdentityListener = (changed: ReadonlySet<RowKey>) => void;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The map. Values are immutable: every write produces a NEW row object, because a projection over
|
|
32
|
-
* this map hands its rows to a signal and a mutated-in-place row is a render that never happens.
|
|
33
|
-
* "One row per id" therefore means one *current value* per id, referenced by every holder at once.
|
|
34
|
-
*/
|
|
35
|
-
export class IdentityMap {
|
|
36
|
-
readonly #values = new Map<RowKey, Row>();
|
|
37
|
-
/** How many projections hold each key. A row nobody holds is dropped — a window is not a leak. */
|
|
38
|
-
readonly #holds = new Map<RowKey, number>();
|
|
39
|
-
readonly #listeners = new Set<IdentityListener>();
|
|
40
|
-
/** Non-null while a batch is open; every write inside one notifies exactly once, at the end. */
|
|
41
|
-
#changed: Set<RowKey> | null = null;
|
|
42
|
-
|
|
43
|
-
peek(scope: RowScope, id: string): Row | undefined {
|
|
44
|
-
return this.#values.get(rowKey(scope, id));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Whole-row write: the value becomes exactly this row. `insert` and a rollback's undo use it. */
|
|
48
|
-
set(scope: RowScope, row: Row): Row {
|
|
49
|
-
const key = rowKey(scope, row.id);
|
|
50
|
-
const current = this.#values.get(key);
|
|
51
|
-
if (current === row) return row;
|
|
52
|
-
this.#values.set(key, row);
|
|
53
|
-
this.#touch(key);
|
|
54
|
-
return row;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Merge changed columns onto the current value. This is the write every server patch, every
|
|
59
|
-
* snapshot row and every `upsert` takes: a projection that selected fewer columns must not blank
|
|
60
|
-
* the columns another projection is rendering, so a write never removes a key. `undefined` in a
|
|
61
|
-
* patch means "leave it alone", exactly as the local store's contract already says.
|
|
62
|
-
*/
|
|
63
|
-
merge(
|
|
64
|
-
scope: RowScope,
|
|
65
|
-
id: string,
|
|
66
|
-
columns: Readonly<Record<string, JsonValue | undefined>>,
|
|
67
|
-
): Row {
|
|
68
|
-
const key = rowKey(scope, id);
|
|
69
|
-
const current = this.#values.get(key);
|
|
70
|
-
const next: JsonObject = { ...current };
|
|
71
|
-
let changed = current === undefined;
|
|
72
|
-
for (const [column, value] of Object.entries(columns)) {
|
|
73
|
-
if (value === undefined || column === 'id') continue;
|
|
74
|
-
if (current === undefined || current[column] !== value) changed = true;
|
|
75
|
-
next[column] = value;
|
|
76
|
-
}
|
|
77
|
-
const row: Row = { ...next, id };
|
|
78
|
-
// A patch that changed nothing must not re-emit: a no-op write re-rendering every holder is
|
|
79
|
-
// how a live query becomes the most expensive thing on the page.
|
|
80
|
-
if (!changed && current !== undefined) return current;
|
|
81
|
-
this.#values.set(key, row);
|
|
82
|
-
this.#touch(key);
|
|
83
|
-
return row;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
retain(scope: RowScope, id: string): void {
|
|
87
|
-
const key = rowKey(scope, id);
|
|
88
|
-
this.#holds.set(key, (this.#holds.get(key) ?? 0) + 1);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The last holder leaving drops the value: an infinite scroll must not retain every row it saw. */
|
|
92
|
-
release(scope: RowScope, id: string): void {
|
|
93
|
-
const key = rowKey(scope, id);
|
|
94
|
-
const holds = this.#holds.get(key);
|
|
95
|
-
if (holds === undefined) return;
|
|
96
|
-
if (holds > 1) {
|
|
97
|
-
this.#holds.set(key, holds - 1);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
this.#holds.delete(key);
|
|
101
|
-
if (this.#values.delete(key)) this.#touch(key);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Every write inside `fn` collapses into one notification — one patch frame, one render. */
|
|
105
|
-
batch<T>(fn: () => T): T {
|
|
106
|
-
if (this.#changed !== null) return fn();
|
|
107
|
-
const collected = new Set<RowKey>();
|
|
108
|
-
this.#changed = collected;
|
|
109
|
-
try {
|
|
110
|
-
return fn();
|
|
111
|
-
} finally {
|
|
112
|
-
this.#changed = null;
|
|
113
|
-
if (collected.size > 0) this.#notify(collected);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
subscribe(listener: IdentityListener): () => void {
|
|
118
|
-
this.#listeners.add(listener);
|
|
119
|
-
return () => {
|
|
120
|
-
this.#listeners.delete(listener);
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Held keys. Tests assert on it; nothing in the client branches on a count. */
|
|
125
|
-
get size(): number {
|
|
126
|
-
return this.#values.size;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
#touch(key: RowKey): void {
|
|
130
|
-
const batch = this.#changed;
|
|
131
|
-
if (batch !== null) {
|
|
132
|
-
batch.add(key);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
this.#notify(new Set([key]));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
#notify(changed: ReadonlySet<RowKey>): void {
|
|
139
|
-
for (const listener of this.#listeners) listener(changed);
|
|
140
|
-
}
|
|
141
|
-
}
|