@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/type-pins.ts
CHANGED
|
@@ -1,83 +1,52 @@
|
|
|
1
|
-
// Compile-time pins for the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// (axiom 3). What it protects is the whole reason `liveHookFor` exists: `useLiveFeed({ orgId })`
|
|
6
|
-
// carrying the query's own input and row types. Lose that and the hook still runs — it just
|
|
7
|
-
// stops catching the typo that makes a subscription match nothing.
|
|
1
|
+
// Compile-time pins for the hook surface. Source, not a `.test.ts`, on purpose: `tsconfig.json`
|
|
2
|
+
// excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a type-level assertion
|
|
3
|
+
// written there can never fail. This module emits nothing anybody imports — a regression is a
|
|
4
|
+
// build error, the only enforcement that counts (axiom 3).
|
|
8
5
|
|
|
9
|
-
import type {
|
|
10
|
-
import type {
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
6
|
+
import type { AsyncState, Row } from '@ultimat3/core';
|
|
7
|
+
import type { LiveHandle, Unsubscribe } from './client-contract';
|
|
8
|
+
import type { QueryAccessor, QueryRef } from './use-query';
|
|
9
|
+
import type { RecordAccessor } from './use-record';
|
|
13
10
|
|
|
14
11
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
15
12
|
type Assert<T extends true> = T;
|
|
16
13
|
|
|
17
14
|
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
18
15
|
|
|
19
|
-
/** The input type a `Query` accepts, read off its call signature rather than its schema. */
|
|
20
|
-
type InputOf<Q> = Q extends (input: infer I, options?: never) => unknown ? I : never;
|
|
21
|
-
|
|
22
|
-
interface FeedInput {
|
|
23
|
-
readonly orgId: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
interface FeedRow {
|
|
27
17
|
readonly id: string;
|
|
28
18
|
readonly title: string;
|
|
29
19
|
}
|
|
30
20
|
|
|
31
|
-
type FeedHook = LiveQueryHook<FeedInput, FeedRow>;
|
|
32
|
-
|
|
33
|
-
/** The hook takes the query's own input, as a value or as the thunk `useLive` reads once. */
|
|
34
|
-
export type _HookInputIsTheQueryInput = Assert<
|
|
35
|
-
Equals<Parameters<FeedHook>[0], FeedInput | (() => FeedInput)>
|
|
36
|
-
>;
|
|
37
|
-
|
|
38
|
-
/** …and answers in the query's own row type, not the wire's `Row`. */
|
|
39
|
-
export type _HookRowsAreTheQueryRows = Assert<
|
|
40
|
-
Equals<ReturnType<ReturnType<FeedHook>>, readonly FeedRow[]>
|
|
41
|
-
>;
|
|
42
|
-
|
|
43
21
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
22
|
+
* `useQuery` answers core's `AsyncState` over the caller's own row type — the value `@ultimat3/ui`
|
|
23
|
+
* takes as `state`, with no adapter between them (plan 101, decision 10).
|
|
46
24
|
*/
|
|
47
|
-
export type
|
|
48
|
-
|
|
25
|
+
export type _QueryAnswersAsyncState = Assert<
|
|
26
|
+
Equals<ReturnType<QueryAccessor<FeedRow>>, AsyncState<readonly FeedRow[]>>
|
|
49
27
|
>;
|
|
50
28
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* place a change to `Query` — losing `isLive`, ceasing to be callable — fails, instead of every
|
|
55
|
-
* component call site in every app.
|
|
56
|
-
*/
|
|
57
|
-
export type _DeclaredQueryBindsToTheHook = Assert<
|
|
58
|
-
[Query] extends [LiveQuerySource<InputOf<Query>, Record<string, unknown>>] ? true : false
|
|
29
|
+
/** …and `useRecord` the same vocabulary, with `undefined` for a record the server removed. */
|
|
30
|
+
export type _RecordAnswersAsyncState = Assert<
|
|
31
|
+
Equals<ReturnType<RecordAccessor<FeedRow>>, AsyncState<FeedRow | undefined>>
|
|
59
32
|
>;
|
|
60
33
|
|
|
61
34
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `[Symbol.dispose]` member while refactoring `unsubscribe`.
|
|
35
|
+
* A query ref carries no server field: a `Query` VALUE in an island drags its whole read path into
|
|
36
|
+
* the bundle, so the ref is a name and two declared facts. A `sql` key is refused.
|
|
65
37
|
*/
|
|
66
|
-
export type
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
/** `channel.subscribe()`'s return must stay both callable and `Disposable`. */
|
|
72
|
-
export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
|
|
38
|
+
export type _QueryRefHasNoServerHalf = Assert<
|
|
39
|
+
[{ readonly name: string; readonly sql: () => unknown }] extends [Required<QueryRef>]
|
|
40
|
+
? false
|
|
41
|
+
: true
|
|
42
|
+
>;
|
|
73
43
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* `hooks.ts` needs and `LiveClient` stops providing fails HERE, at the build, rather than at the
|
|
79
|
-
* one app that registered a real client.
|
|
80
|
-
*/
|
|
81
|
-
export type _LiveClientSatisfiesTheHookSeam = Assert<
|
|
82
|
-
[LiveClient] extends [LiveClientLike] ? true : false
|
|
44
|
+
/** Every handle a caller gets back stays `Disposable`, so `using` releases it on scope exit. */
|
|
45
|
+
export type _LiveHandleIsDisposable = Assert<[LiveHandle] extends [Disposable] ? true : false>;
|
|
46
|
+
export type _QueryAccessorIsDisposable = Assert<
|
|
47
|
+
[QueryAccessor] extends [Disposable] ? true : false
|
|
83
48
|
>;
|
|
49
|
+
export type _RecordAccessorIsDisposable = Assert<
|
|
50
|
+
[RecordAccessor<Row>] extends [Disposable] ? true : false
|
|
51
|
+
>;
|
|
52
|
+
export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A declared channel, held by a component. Every holder of one topic on the page shares ONE
|
|
2
|
+
// membership on the page socket; its `records` land in the store (read them with `useRecord` /
|
|
3
|
+
// `useQuery`), and only `events` and presence reach the handlers given here.
|
|
4
|
+
|
|
5
|
+
import type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
|
|
6
|
+
import { pageSocket } from './page-socket';
|
|
7
|
+
import { isServerRender, signalFor } from './reactivity';
|
|
8
|
+
import type { PresenceMember } from './sync-protocol';
|
|
9
|
+
|
|
10
|
+
/** The membership's state, callable, plus its release (Solid: `onCleanup`). */
|
|
11
|
+
export type ChannelAccessor = (() => ChannelState) & {
|
|
12
|
+
release(): void;
|
|
13
|
+
[Symbol.dispose](): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const nothing = (): void => undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Takes the `channel()` DECLARATION (any `ChannelRef` — a declaration is one), never a topic
|
|
20
|
+
* string: the declaration is the only thing that may spell a topic (`bun run channel-literals`
|
|
21
|
+
* holds it), so both halves spell it one way.
|
|
22
|
+
*/
|
|
23
|
+
export function useChannel<K extends string>(
|
|
24
|
+
declared: ChannelRef<K>,
|
|
25
|
+
params: Readonly<Record<K, string>>,
|
|
26
|
+
handlers?: ChannelHandlers,
|
|
27
|
+
): ChannelAccessor {
|
|
28
|
+
const signal = signalFor('useChannel');
|
|
29
|
+
if (isServerRender()) {
|
|
30
|
+
return Object.assign((): ChannelState => 'joining', {
|
|
31
|
+
release: nothing,
|
|
32
|
+
[Symbol.dispose]: nothing,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const membership = pageSocket('useChannel').holdChannel(declared, params, handlers);
|
|
36
|
+
const [version, setVersion] = signal(0);
|
|
37
|
+
const off = membership.onChange(() => setVersion(version() + 1));
|
|
38
|
+
const read = (): ChannelState => {
|
|
39
|
+
version();
|
|
40
|
+
return membership.state();
|
|
41
|
+
};
|
|
42
|
+
const release = (): void => {
|
|
43
|
+
off();
|
|
44
|
+
membership.release();
|
|
45
|
+
};
|
|
46
|
+
return Object.assign(read, { release, [Symbol.dispose]: release });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Who is in a channel's room, as the node's presence set says — one entry per member id. */
|
|
50
|
+
export type PresenceAccessor = (() => readonly PresenceMember[]) & {
|
|
51
|
+
release(): void;
|
|
52
|
+
[Symbol.dispose](): void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The roster of a channel declared with `events: true`: a `sync` replaces it, `join` / `update`
|
|
57
|
+
* upsert a member, `leave` removes one. It is a membership of the channel like any other — the
|
|
58
|
+
* same one `useChannel` holds, shared on the page — so holding both costs one subscribe.
|
|
59
|
+
*/
|
|
60
|
+
export function usePresence<K extends string>(
|
|
61
|
+
declared: ChannelRef<K>,
|
|
62
|
+
params: Readonly<Record<K, string>>,
|
|
63
|
+
): PresenceAccessor {
|
|
64
|
+
const signal = signalFor('usePresence');
|
|
65
|
+
if (isServerRender()) {
|
|
66
|
+
return Object.assign((): readonly PresenceMember[] => [], {
|
|
67
|
+
release: nothing,
|
|
68
|
+
[Symbol.dispose]: nothing,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const [version, setVersion] = signal(0);
|
|
72
|
+
let members = new Map<string, PresenceMember>();
|
|
73
|
+
const membership = pageSocket('usePresence').holdChannel(declared, params, {
|
|
74
|
+
onPresence: (event) => {
|
|
75
|
+
if (event.presence === 'sync') members = new Map();
|
|
76
|
+
for (const member of event.members) {
|
|
77
|
+
if (event.presence === 'leave') members.delete(member.id);
|
|
78
|
+
else members.set(member.id, member);
|
|
79
|
+
}
|
|
80
|
+
setVersion(version() + 1);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const read = (): readonly PresenceMember[] => {
|
|
84
|
+
version();
|
|
85
|
+
return [...members.values()];
|
|
86
|
+
};
|
|
87
|
+
return Object.assign(read, { release: membership.release, [Symbol.dispose]: membership.release });
|
|
88
|
+
}
|
|
@@ -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,227 @@
|
|
|
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
|
+
// Older writes still wait in the outbox: this one queues BEHIND them rather than overtaking
|
|
138
|
+
// them over HTTP — a like queued offline and the unlike made once the network was back could
|
|
139
|
+
// otherwise land swapped. The replay sends the queue in order, this write last, under its key.
|
|
140
|
+
const queued = peekOutbox();
|
|
141
|
+
if (queued !== undefined && queued.pending().length > 0) {
|
|
142
|
+
try {
|
|
143
|
+
await queued.enqueue({ key, name: mutator.name, input });
|
|
144
|
+
void queued.replay().catch(() => undefined);
|
|
145
|
+
return undefined;
|
|
146
|
+
} finally {
|
|
147
|
+
count(writes, mutator.name, -1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let output: unknown;
|
|
151
|
+
/** The records the answer carried, `type:key` — what the overlay may be settled against. */
|
|
152
|
+
const carried = new Set<string>();
|
|
153
|
+
try {
|
|
154
|
+
output = await clientTransport({
|
|
155
|
+
method: 'POST',
|
|
156
|
+
url: actionPath(mutator.name),
|
|
157
|
+
body: input,
|
|
158
|
+
idempotencyKey: key,
|
|
159
|
+
onEnvelope: (envelope) => carriedBy(envelope, carried),
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const failure = transportFailure(error);
|
|
163
|
+
// No response at all: the write is the outbox's now, under the SAME idempotency key, and its
|
|
164
|
+
// overlay stays on screen until the replay settles or refuses it. Only a page the boot opened
|
|
165
|
+
// an outbox on can promise that; with none (no boot, so nothing on this page persists) the
|
|
166
|
+
// write is refused like any other, never held in a memory queue a reload would silently lose.
|
|
167
|
+
const outbox = failure === 'network' ? await bootedOutbox(page) : undefined;
|
|
168
|
+
if (outbox !== undefined) {
|
|
169
|
+
await outbox.enqueue({ key, name: mutator.name, input });
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
if (failure === 'body') {
|
|
173
|
+
// A 2xx whose body was not JSON: the write may well have landed, so it is neither queued
|
|
174
|
+
// (a replay would be a second attempt) nor taken back — its overlay waits for the next
|
|
175
|
+
// server row it touched. The caller is still told: nothing here can confirm it.
|
|
176
|
+
page.store.awaitServer(key);
|
|
177
|
+
writes.failed += 1;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
page.store.drop(key);
|
|
181
|
+
// A write superseded by a principal change DID land; it is the previous principal's, and
|
|
182
|
+
// nothing about it is this page's failure to report.
|
|
183
|
+
if (!isSuperseded(error)) writes.failed += 1;
|
|
184
|
+
throw error;
|
|
185
|
+
} finally {
|
|
186
|
+
count(writes, mutator.name, -1);
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
// A row the answer did not carry keeps its overlay until the server's row reaches it.
|
|
190
|
+
page.store.settle(key, carried);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// A custom merge that answered no row: the server's truth stands, and the caller is told.
|
|
193
|
+
page.store.drop(key);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
return output;
|
|
197
|
+
};
|
|
198
|
+
Object.defineProperty(call, 'pending', {
|
|
199
|
+
get: (): number => {
|
|
200
|
+
version();
|
|
201
|
+
return writes?.pending.get(mutator.name) ?? 0;
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
Object.assign(call, { release, [Symbol.dispose]: release });
|
|
205
|
+
// `defineProperty` cannot widen a function type, so the assembled shape is asserted once, here.
|
|
206
|
+
return call as Mutate;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Every write on the page, as two counts. Zero on a server render, where nothing is written. */
|
|
210
|
+
export function useMutationQueue(): MutationQueue {
|
|
211
|
+
const writes = isServerRender() ? undefined : pageRealtime().writes;
|
|
212
|
+
const [version, release] = watch('useMutationQueue', writes);
|
|
213
|
+
return {
|
|
214
|
+
release,
|
|
215
|
+
[Symbol.dispose]: release,
|
|
216
|
+
get pending() {
|
|
217
|
+
version();
|
|
218
|
+
let total = 0;
|
|
219
|
+
for (const n of writes?.pending.values() ?? []) total += n;
|
|
220
|
+
return total;
|
|
221
|
+
},
|
|
222
|
+
get failed() {
|
|
223
|
+
version();
|
|
224
|
+
return writes?.failed ?? 0;
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|