@rxferrix/client 0.0.0-stage → 0.1.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 (97) hide show
  1. package/dist/api.d.ts +60 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/api.js +22 -0
  4. package/dist/api.js.map +1 -0
  5. package/dist/auth.d.ts +251 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +531 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/bytes.d.ts +21 -0
  10. package/dist/bytes.d.ts.map +1 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/bytes.js.map +1 -0
  13. package/dist/canonical.d.ts +13 -0
  14. package/dist/canonical.d.ts.map +1 -0
  15. package/dist/canonical.js +66 -0
  16. package/dist/canonical.js.map +1 -0
  17. package/dist/client.d.ts +170 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +638 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/diff.d.ts +20 -0
  22. package/dist/diff.d.ts.map +1 -0
  23. package/dist/diff.js +100 -0
  24. package/dist/diff.js.map +1 -0
  25. package/dist/errors.d.ts +24 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +34 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/index.d.ts +14 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/local_query_set.d.ts +56 -0
  34. package/dist/local_query_set.d.ts.map +1 -0
  35. package/dist/local_query_set.js +108 -0
  36. package/dist/local_query_set.js.map +1 -0
  37. package/dist/logging.d.ts +15 -0
  38. package/dist/logging.d.ts.map +1 -0
  39. package/dist/logging.js +17 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/optimistic_updates.d.ts +48 -0
  42. package/dist/optimistic_updates.d.ts.map +1 -0
  43. package/dist/optimistic_updates.js +113 -0
  44. package/dist/optimistic_updates.js.map +1 -0
  45. package/dist/paginated_query.d.ts +118 -0
  46. package/dist/paginated_query.d.ts.map +1 -0
  47. package/dist/paginated_query.js +199 -0
  48. package/dist/paginated_query.js.map +1 -0
  49. package/dist/protocol.d.ts +136 -0
  50. package/dist/protocol.d.ts.map +1 -0
  51. package/dist/protocol.js +11 -0
  52. package/dist/protocol.js.map +1 -0
  53. package/dist/query_state.d.ts +16 -0
  54. package/dist/query_state.d.ts.map +1 -0
  55. package/dist/query_state.js +2 -0
  56. package/dist/query_state.js.map +1 -0
  57. package/dist/remote_query_set.d.ts +37 -0
  58. package/dist/remote_query_set.d.ts.map +1 -0
  59. package/dist/remote_query_set.js +87 -0
  60. package/dist/remote_query_set.js.map +1 -0
  61. package/dist/request_manager.d.ts +57 -0
  62. package/dist/request_manager.d.ts.map +1 -0
  63. package/dist/request_manager.js +163 -0
  64. package/dist/request_manager.js.map +1 -0
  65. package/dist/storage.d.ts +45 -0
  66. package/dist/storage.d.ts.map +1 -0
  67. package/dist/storage.js +120 -0
  68. package/dist/storage.js.map +1 -0
  69. package/dist/testing.d.ts +68 -0
  70. package/dist/testing.d.ts.map +1 -0
  71. package/dist/testing.js +97 -0
  72. package/dist/testing.js.map +1 -0
  73. package/dist/web_socket_manager.d.ts +88 -0
  74. package/dist/web_socket_manager.d.ts.map +1 -0
  75. package/dist/web_socket_manager.js +291 -0
  76. package/dist/web_socket_manager.js.map +1 -0
  77. package/package.json +50 -4
  78. package/src/api.ts +86 -0
  79. package/src/auth.ts +636 -0
  80. package/src/bytes.ts +57 -0
  81. package/src/canonical.ts +61 -0
  82. package/src/client.ts +740 -0
  83. package/src/diff.ts +87 -0
  84. package/src/errors.ts +36 -0
  85. package/src/index.ts +60 -0
  86. package/src/local_query_set.ts +130 -0
  87. package/src/logging.ts +26 -0
  88. package/src/optimistic_updates.ts +137 -0
  89. package/src/paginated_query.ts +299 -0
  90. package/src/protocol.ts +152 -0
  91. package/src/query_state.ts +12 -0
  92. package/src/remote_query_set.ts +105 -0
  93. package/src/request_manager.ts +199 -0
  94. package/src/storage.ts +171 -0
  95. package/src/testing.ts +120 -0
  96. package/src/web_socket_manager.ts +322 -0
  97. package/README.md +0 -3
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Reactive pagination on top of {@link RxFerrixClient.watchQuery}.
3
+ *
4
+ * A paginated query is a server query that takes `paginationOpts: PaginationOpts` and returns a
5
+ * `PaginationResult<Item>` (in Rust: `Query::paginate`). The client subscribes to it once per loaded page:
6
+ *
7
+ * - Page 0 is `{ ...args, paginationOpts: { numItems: initialNumItems, cursor: null } }`.
8
+ * - `loadMore(n)` subscribes to the next page, starting at the last page's `continueCursor`.
9
+ * - When a page gets a successor, it is re-subscribed with `endCursor` pinned to the cursor the successor starts at.
10
+ * A pinned page covers a fixed span of the index, so inserts and deletes make it grow or shrink instead of
11
+ * shifting documents into (or out of) its neighbours: no duplicates or gaps between pages. The unpinned
12
+ * subscription stays visible until the pinned one has a result, so the swap does not flash a loading state.
13
+ *
14
+ * Only the last page is unpinned; it grows up to its `numItems` when documents are added after it.
15
+ */
16
+ import type { ArgsOf, FunctionReference, OutputOf } from "./api.js";
17
+ import { jsonEqual, normalizeArgs } from "./canonical.js";
18
+ import type { RxFerrixClient, QueryWatch } from "./client.js";
19
+ import type { RxFerrixError } from "./errors.js";
20
+ import type { OptimisticLocalStore } from "./optimistic_updates.js";
21
+ import type { QueryState } from "./query_state.js";
22
+
23
+ /** Which page to read: the `paginationOpts` argument of a paginated query (Rust `rxferrix::PaginationOpts`). */
24
+ export interface PaginationOpts {
25
+ /** How many items to return (ignored when `endCursor` is set). */
26
+ numItems: number;
27
+ /** Where the page starts: `null` for the start, otherwise a previous page's `continueCursor`. */
28
+ cursor: string | null;
29
+ /** Where the page ends. When set, the page holds every item between `cursor` and `endCursor`. */
30
+ endCursor?: string | null;
31
+ }
32
+
33
+ /** One page of results (Rust `rxferrix::PaginationResult<T>`). */
34
+ export interface PaginationResult<T> {
35
+ page: T[];
36
+ /** True when there is nothing after this page. */
37
+ isDone: boolean;
38
+ /** Where the next page starts. */
39
+ continueCursor: string;
40
+ }
41
+
42
+ /** A reference to a query whose args include `paginationOpts` and whose output is a {@link PaginationResult}. */
43
+ export type PaginatedQueryReference<Item = any> = FunctionReference<
44
+ "query",
45
+ { paginationOpts: PaginationOpts },
46
+ PaginationResult<Item>
47
+ >;
48
+
49
+ /** The item type of a paginated query. */
50
+ export type PaginatedQueryItem<Q> = OutputOf<Q> extends PaginationResult<infer Item> ? Item : never;
51
+
52
+ /** The args of a paginated query without `paginationOpts` (the client manages those). */
53
+ export type PaginatedQueryArgs<Q> = Omit<ArgsOf<Q>, "paginationOpts">;
54
+
55
+ export type PaginationStatus = "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted";
56
+
57
+ export interface PaginatedQueryResult<Item> {
58
+ /** The items of every loaded page, in order. */
59
+ readonly results: Item[];
60
+ readonly status: PaginationStatus;
61
+ /** True while the first page or a further page is loading (and no page failed). */
62
+ readonly isLoading: boolean;
63
+ /** The error of the first failed page, if any. A failed page counts as not loaded for `status`. */
64
+ readonly error: RxFerrixError | undefined;
65
+ /**
66
+ * Loads `numItems` more items. Does nothing (and returns false) unless the last page is loaded and not done
67
+ * (status `"CanLoadMore"`).
68
+ */
69
+ loadMore(numItems: number): boolean;
70
+ }
71
+
72
+ export interface PaginatedQueryOptions {
73
+ /** The size of the first page. */
74
+ initialNumItems: number;
75
+ }
76
+
77
+ /** A framework-agnostic paginated query. Pages are subscribed while at least one `subscribe` callback is registered. */
78
+ export interface PaginatedQuery<Item> {
79
+ /** Registers `callback` to be called whenever {@link getCurrent} changes. Returns the unsubscribe function. */
80
+ subscribe(callback: () => void): () => void;
81
+ /** The current results. The object is referentially stable until something visible changes. */
82
+ getCurrent(): PaginatedQueryResult<Item>;
83
+ }
84
+
85
+ interface PageSub<Item> {
86
+ readonly opts: PaginationOpts;
87
+ readonly watch: QueryWatch<PaginationResult<Item>>;
88
+ /** Set while the subscription is active (the paginated query has listeners). */
89
+ unsubscribe: (() => void) | null;
90
+ }
91
+
92
+ interface Page<Item> {
93
+ current: PageSub<Item>;
94
+ /** The pinned replacement of `current`, until it has a result. */
95
+ pending: PageSub<Item> | null;
96
+ }
97
+
98
+ function validateNumItems(numItems: number, what: string): void {
99
+ if (!Number.isInteger(numItems) || numItems < 1) {
100
+ throw new Error(`${what} must be a positive integer, got ${numItems}`);
101
+ }
102
+ }
103
+
104
+ /** @internal Created by {@link RxFerrixClient.paginatedQuery}. */
105
+ export class PaginatedQueryImpl<Item> implements PaginatedQuery<Item> {
106
+ private readonly pages: Page<Item>[] = [];
107
+ private readonly listeners = new Set<() => void>();
108
+ private readonly args: Record<string, unknown>;
109
+ private snapshot: PaginatedQueryResult<Item> | null = null;
110
+ /** The page states `snapshot` was built from. */
111
+ private snapshotStates: QueryState[] = [];
112
+
113
+ constructor(
114
+ private readonly client: RxFerrixClient,
115
+ private readonly ref: FunctionReference<"query">,
116
+ args: Record<string, unknown>,
117
+ options: PaginatedQueryOptions,
118
+ ) {
119
+ validateNumItems(options?.initialNumItems, "initialNumItems");
120
+ const { paginationOpts: _ignored, ...rest } = normalizeArgs(args);
121
+ this.args = rest;
122
+ this.pages.push({ current: this.makeSub({ numItems: options.initialNumItems, cursor: null }), pending: null });
123
+ }
124
+
125
+ subscribe(callback: () => void): () => void {
126
+ const entry = () => callback();
127
+ this.listeners.add(entry);
128
+ if (this.listeners.size === 1) {
129
+ for (const page of this.pages) {
130
+ this.activate(page.current);
131
+ if (page.pending) this.activate(page.pending);
132
+ }
133
+ }
134
+ let active = true;
135
+ return () => {
136
+ if (!active) return;
137
+ active = false;
138
+ this.listeners.delete(entry);
139
+ if (this.listeners.size === 0) {
140
+ for (const page of this.pages) {
141
+ this.deactivate(page.current);
142
+ if (page.pending) this.deactivate(page.pending);
143
+ }
144
+ }
145
+ };
146
+ }
147
+
148
+ getCurrent(): PaginatedQueryResult<Item> {
149
+ const states = this.pages.map((p) => this.displayed(p));
150
+ if (this.snapshot && states.length === this.snapshotStates.length && states.every((s, i) => s === this.snapshotStates[i])) {
151
+ return this.snapshot;
152
+ }
153
+ let results: Item[] = [];
154
+ let loading = false;
155
+ let error: RxFerrixError | undefined;
156
+ for (const state of states) {
157
+ if (state.status === "success") results = results.concat(state.value.page);
158
+ else if (state.status === "loading") loading = true;
159
+ else error ??= state.error;
160
+ }
161
+ const last = states[states.length - 1];
162
+ let status: PaginationStatus;
163
+ if (loading || error !== undefined) {
164
+ status = results.length === 0 && states[0]?.status !== "success" ? "LoadingFirstPage" : "LoadingMore";
165
+ } else if (last?.status === "success" && last.value.isDone) {
166
+ status = "Exhausted";
167
+ } else {
168
+ status = "CanLoadMore";
169
+ }
170
+ this.snapshotStates = states;
171
+ const previous = this.snapshot;
172
+ // E.g. a page swapped for its pinned copy: new state objects, same items. Keep the snapshot (no re-render).
173
+ if (previous && previous.status === status && previous.error === error && jsonEqual(previous.results, results)) {
174
+ return previous;
175
+ }
176
+ this.snapshot = {
177
+ results,
178
+ status,
179
+ isLoading: error === undefined && (status === "LoadingFirstPage" || status === "LoadingMore"),
180
+ error,
181
+ loadMore: (numItems: number) => this.loadMore(numItems),
182
+ };
183
+ return this.snapshot;
184
+ }
185
+
186
+ private loadMore(numItems: number): boolean {
187
+ validateNumItems(numItems, "loadMore(numItems)");
188
+ const last = this.pages[this.pages.length - 1];
189
+ if (!last) return false;
190
+ const state = this.displayed(last);
191
+ if (state.status !== "success" || state.value.isDone) return false;
192
+ const cursor = state.value.continueCursor;
193
+ // Pin the page to where its successor starts (it covers the same span, so it shows the same items).
194
+ const shown = last.pending && last.pending.watch.localQueryState().status !== "loading" ? last.pending : last.current;
195
+ if (shown.opts.endCursor == null) {
196
+ if (last.pending) this.deactivate(last.pending);
197
+ last.pending = this.makeSub({ ...shown.opts, endCursor: cursor });
198
+ if (this.listeners.size > 0) this.activate(last.pending);
199
+ }
200
+ const next: Page<Item> = { current: this.makeSub({ numItems, cursor }), pending: null };
201
+ this.pages.push(next);
202
+ if (this.listeners.size > 0) this.activate(next.current);
203
+ this.notify();
204
+ return true;
205
+ }
206
+
207
+ /** The state a page shows: its pinned replacement once that has a result, otherwise the current subscription. */
208
+ private displayed(page: Page<Item>): QueryState<PaginationResult<Item>> {
209
+ if (page.pending) {
210
+ const pending = page.pending.watch.localQueryState();
211
+ if (pending.status !== "loading") return pending;
212
+ }
213
+ return page.current.watch.localQueryState();
214
+ }
215
+
216
+ private makeSub(opts: PaginationOpts): PageSub<Item> {
217
+ const args = { ...this.args, paginationOpts: opts };
218
+ const watch = this.client.watchQuery(this.ref, args as never) as QueryWatch<PaginationResult<Item>>;
219
+ return { opts, watch, unsubscribe: null };
220
+ }
221
+
222
+ private activate(sub: PageSub<Item>): void {
223
+ if (!sub.unsubscribe) sub.unsubscribe = sub.watch.onUpdate(() => this.onPageUpdate());
224
+ }
225
+
226
+ private deactivate(sub: PageSub<Item>): void {
227
+ sub.unsubscribe?.();
228
+ sub.unsubscribe = null;
229
+ }
230
+
231
+ private onPageUpdate(): void {
232
+ // Complete the swaps whose pinned page has a result. Done here (not in getCurrent) to keep reads side-effect free.
233
+ for (const page of this.pages) {
234
+ if (page.pending && page.pending.watch.localQueryState().status !== "loading") {
235
+ this.deactivate(page.current);
236
+ page.current = page.pending;
237
+ page.pending = null;
238
+ }
239
+ }
240
+ this.notify();
241
+ }
242
+
243
+ private notify(): void {
244
+ const before = this.snapshot;
245
+ if (this.getCurrent() === before) return;
246
+ for (const listener of [...this.listeners]) listener();
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------------------------------------------------
251
+ // Optimistic update helpers
252
+
253
+ /** Does every key of `argsToMatch` equal the corresponding arg of a page? */
254
+ function argsMatch(args: Record<string, unknown>, argsToMatch: Record<string, unknown> | undefined): boolean {
255
+ if (!argsToMatch) return true;
256
+ return Object.entries(argsToMatch).every(([k, v]) => v === undefined || jsonEqual(args[k], v));
257
+ }
258
+
259
+ /**
260
+ * Every loaded page of a paginated query in the optimistic store, with its args (including `paginationOpts`).
261
+ * Pass `argsToMatch` (args without `paginationOpts`, or a subset of them) to keep only one paginated query's pages.
262
+ */
263
+ export function getAllPages<Q extends PaginatedQueryReference>(
264
+ localStore: OptimisticLocalStore,
265
+ ref: Q,
266
+ argsToMatch?: Partial<PaginatedQueryArgs<Q>>,
267
+ ): { args: ArgsOf<Q>; value: PaginationResult<PaginatedQueryItem<Q>> }[] {
268
+ const out: { args: ArgsOf<Q>; value: PaginationResult<PaginatedQueryItem<Q>> }[] = [];
269
+ for (const { args, value } of localStore.getAllQueries(ref)) {
270
+ if (value === undefined || !argsMatch(args as Record<string, unknown>, argsToMatch as Record<string, unknown>)) continue;
271
+ out.push({ args, value: value as PaginationResult<PaginatedQueryItem<Q>> });
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /**
277
+ * Optimistically inserts `item` at the top of the first page of a paginated query (every paginated query of `ref`
278
+ * whose args match `argsToMatch`). Typical for "newest first" lists:
279
+ *
280
+ * ```ts
281
+ * const send = useMutation(api.messages.send).withOptimisticUpdate((store, args) => {
282
+ * insertAtTop({ paginatedQuery: api.messages.list, argsToMatch: { channel: args.channel }, localQueryStore: store,
283
+ * item: { _id: "optimistic" as Id<"messages">, _creationTime: Date.now(), ...args } });
284
+ * });
285
+ * ```
286
+ */
287
+ export function insertAtTop<Q extends PaginatedQueryReference>(options: {
288
+ paginatedQuery: Q;
289
+ argsToMatch?: Partial<PaginatedQueryArgs<Q>>;
290
+ localQueryStore: OptimisticLocalStore;
291
+ item: PaginatedQueryItem<Q>;
292
+ }): void {
293
+ const { paginatedQuery, argsToMatch, localQueryStore, item } = options;
294
+ for (const { args, value } of getAllPages(localQueryStore, paginatedQuery, argsToMatch)) {
295
+ const opts = (args as { paginationOpts?: PaginationOpts }).paginationOpts;
296
+ if (opts?.cursor !== null) continue;
297
+ localQueryStore.setQuery(paginatedQuery, args, { ...value, page: [item, ...value.page] } as OutputOf<Q>);
298
+ }
299
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Wire types for the RxFerrix sync protocol (v1). See `docs/PROTOCOL.md` in the repository; that document is normative.
3
+ */
4
+
5
+ export type QueryId = number;
6
+ export type RequestId = number;
7
+ export type JSONValue = null | boolean | number | string | JSONValue[] | { [key: string]: JSONValue };
8
+
9
+ export const CLIENT_VERSION = "0.1.0";
10
+
11
+ export interface StateVersion {
12
+ querySet: number;
13
+ identity: number;
14
+ ts: number;
15
+ }
16
+
17
+ /** `{ code, data }` attached to user errors. */
18
+ export interface ErrorData {
19
+ code: string;
20
+ data?: unknown;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------------------------------------------
24
+ // Client -> server
25
+
26
+ export interface ConnectMessage {
27
+ type: "Connect";
28
+ sessionId: string;
29
+ connectionCount: number;
30
+ lastCloseReason: string | null;
31
+ clientVersion: string;
32
+ /** Optional protocol features this client supports: `"diffs"` (results may arrive as diffs, see `diff.ts`). */
33
+ features?: string[];
34
+ }
35
+
36
+ export type QuerySetModification =
37
+ | { type: "Add"; queryId: QueryId; udfPath: string; args: Record<string, unknown> }
38
+ | { type: "Remove"; queryId: QueryId };
39
+
40
+ export interface ModifyQuerySetMessage {
41
+ type: "ModifyQuerySet";
42
+ baseVersion: number;
43
+ newVersion: number;
44
+ modifications: QuerySetModification[];
45
+ }
46
+
47
+ export interface MutationMessage {
48
+ type: "Mutation";
49
+ requestId: RequestId;
50
+ udfPath: string;
51
+ args: Record<string, unknown>;
52
+ }
53
+
54
+ export interface ActionMessage {
55
+ type: "Action";
56
+ requestId: RequestId;
57
+ udfPath: string;
58
+ args: Record<string, unknown>;
59
+ }
60
+
61
+ export interface AuthenticateMessage {
62
+ type: "Authenticate";
63
+ baseVersion: number;
64
+ token: string | null;
65
+ }
66
+
67
+ export interface PongMessage {
68
+ type: "Pong";
69
+ }
70
+
71
+ export type ClientMessage =
72
+ | ConnectMessage
73
+ | ModifyQuerySetMessage
74
+ | MutationMessage
75
+ | ActionMessage
76
+ | AuthenticateMessage
77
+ | PongMessage;
78
+
79
+ // ---------------------------------------------------------------------------------------------------------------
80
+ // Server -> client
81
+
82
+ export type StateModification =
83
+ | { type: "QueryUpdated"; queryId: QueryId; value: unknown; logLines?: string[] }
84
+ /** The new result as a diff from the result this connection last received for the query (`diff.ts`). */
85
+ | { type: "QueryUpdated"; queryId: QueryId; diff: unknown; logLines?: string[] }
86
+ | {
87
+ type: "QueryFailed";
88
+ queryId: QueryId;
89
+ errorMessage: string;
90
+ errorData?: ErrorData | null;
91
+ logLines?: string[];
92
+ }
93
+ | { type: "QueryRemoved"; queryId: QueryId };
94
+
95
+ export interface TransitionMessage {
96
+ type: "Transition";
97
+ startVersion: StateVersion;
98
+ endVersion: StateVersion;
99
+ modifications: StateModification[];
100
+ }
101
+
102
+ export type MutationResponseMessage =
103
+ | { type: "MutationResponse"; requestId: RequestId; success: true; result: unknown; ts: number; logLines?: string[] }
104
+ | {
105
+ type: "MutationResponse";
106
+ requestId: RequestId;
107
+ success: false;
108
+ errorMessage: string;
109
+ errorData?: ErrorData | null;
110
+ logLines?: string[];
111
+ };
112
+
113
+ export type ActionResponseMessage =
114
+ | { type: "ActionResponse"; requestId: RequestId; success: true; result: unknown; logLines?: string[] }
115
+ | {
116
+ type: "ActionResponse";
117
+ requestId: RequestId;
118
+ success: false;
119
+ errorMessage: string;
120
+ errorData?: ErrorData | null;
121
+ logLines?: string[];
122
+ };
123
+
124
+ export interface FatalErrorMessage {
125
+ type: "FatalError";
126
+ error: string;
127
+ }
128
+
129
+ export interface AuthErrorMessage {
130
+ type: "AuthError";
131
+ error: string;
132
+ }
133
+
134
+ export interface PingMessage {
135
+ type: "Ping";
136
+ }
137
+
138
+ export type ServerMessage =
139
+ | TransitionMessage
140
+ | MutationResponseMessage
141
+ | ActionResponseMessage
142
+ | FatalErrorMessage
143
+ | AuthErrorMessage
144
+ | PingMessage;
145
+
146
+ export function versionsEqual(a: StateVersion, b: StateVersion): boolean {
147
+ return a.querySet === b.querySet && a.identity === b.identity && a.ts === b.ts;
148
+ }
149
+
150
+ export function formatVersion(v: StateVersion): string {
151
+ return `{querySet:${v.querySet},identity:${v.identity},ts:${v.ts}}`;
152
+ }
@@ -0,0 +1,12 @@
1
+ import type { RxFerrixError } from "./errors.js";
2
+
3
+ /**
4
+ * The local state of a query. Objects are reused while the visible value does not change, so they can be compared
5
+ * by reference (e.g. as a `useSyncExternalStore` snapshot).
6
+ */
7
+ export type QueryState<T = unknown> =
8
+ | { readonly status: "loading" }
9
+ | { readonly status: "success"; readonly value: T }
10
+ | { readonly status: "error"; readonly error: RxFerrixError };
11
+
12
+ export const LOADING: QueryState<never> = Object.freeze({ status: "loading" as const });
@@ -0,0 +1,105 @@
1
+ import { applyDiff, DiffMismatch } from "./diff.js";
2
+ import { RxFerrixError } from "./errors.js";
3
+ import {
4
+ formatVersion,
5
+ versionsEqual,
6
+ type QueryId,
7
+ type StateModification,
8
+ type StateVersion,
9
+ type TransitionMessage,
10
+ } from "./protocol.js";
11
+ import type { QueryState } from "./query_state.js";
12
+
13
+ export class TransitionVersionMismatch extends Error {
14
+ constructor(expected: StateVersion, got: StateVersion) {
15
+ super(`Transition startVersion ${formatVersion(got)} does not match client version ${formatVersion(expected)}`);
16
+ }
17
+ }
18
+
19
+ export interface TransitionResult {
20
+ /** Queries whose server result changed (updated, failed or removed). */
21
+ changed: QueryId[];
22
+ logLines: { queryId: QueryId; lines: string[] }[];
23
+ }
24
+
25
+ /**
26
+ * The server's view of the world for the current socket: the StateVersion and the latest result for every query.
27
+ * Transitions are validated (`startVersion` must equal the current version) and then applied atomically.
28
+ */
29
+ export class RemoteQuerySet {
30
+ private _version: StateVersion = { querySet: 0, identity: 0, ts: 0 };
31
+ private readonly results = new Map<QueryId, QueryState>();
32
+
33
+ get version(): StateVersion {
34
+ return this._version;
35
+ }
36
+
37
+ result(queryId: QueryId): QueryState | undefined {
38
+ return this.results.get(queryId);
39
+ }
40
+
41
+ /**
42
+ * Applies a transition. Modifications for queries that `isKnown` rejects (removed locally while the transition was
43
+ * in flight) are ignored. Throws {@link TransitionVersionMismatch} without changing anything when the transition
44
+ * does not start at the current version, and {@link DiffMismatch} when a result diff doesn't fit the result held.
45
+ */
46
+ transition(message: TransitionMessage, isKnown: (queryId: QueryId) => boolean): TransitionResult {
47
+ if (!versionsEqual(message.startVersion, this._version)) {
48
+ throw new TransitionVersionMismatch(this._version, message.startVersion);
49
+ }
50
+ // Diffs are applied first, against the results as they were: if one doesn't fit, nothing changes.
51
+ const diffed = new Map<StateModification, unknown>();
52
+ for (const modification of message.modifications ?? []) {
53
+ if (modification.type !== "QueryUpdated" || !("diff" in modification) || !isKnown(modification.queryId)) continue;
54
+ const current = this.results.get(modification.queryId);
55
+ if (current?.status !== "success") {
56
+ throw new DiffMismatch(`query ${modification.queryId} has no result to apply a diff to`);
57
+ }
58
+ diffed.set(modification, applyDiff(current.value, modification.diff));
59
+ }
60
+ const changed: QueryId[] = [];
61
+ const logLines: TransitionResult["logLines"] = [];
62
+ for (const modification of message.modifications ?? []) {
63
+ const queryId = modification.queryId;
64
+ if (!isKnown(queryId)) continue;
65
+ switch (modification.type) {
66
+ case "QueryUpdated":
67
+ this.results.set(queryId, {
68
+ status: "success",
69
+ value: "diff" in modification ? diffed.get(modification) : modification.value,
70
+ });
71
+ break;
72
+ case "QueryFailed":
73
+ this.results.set(queryId, {
74
+ status: "error",
75
+ error: RxFerrixError.fromServer(modification.errorMessage, modification.errorData),
76
+ });
77
+ break;
78
+ case "QueryRemoved":
79
+ this.results.delete(queryId);
80
+ break;
81
+ default:
82
+ continue;
83
+ }
84
+ changed.push(queryId);
85
+ if ("logLines" in modification && modification.logLines && modification.logLines.length > 0) {
86
+ logLines.push({ queryId, lines: modification.logLines });
87
+ }
88
+ }
89
+ this._version = { ...message.endVersion };
90
+ return { changed, logLines };
91
+ }
92
+
93
+ /** Forgets a query's result (it was removed from the local query set). */
94
+ remove(queryId: QueryId): void {
95
+ this.results.delete(queryId);
96
+ }
97
+
98
+ /**
99
+ * A new socket starts from a fresh server state (all versions 0). Results are kept so the UI keeps showing the
100
+ * last known data until the re-subscribed queries report again.
101
+ */
102
+ resetVersion(): void {
103
+ this._version = { querySet: 0, identity: 0, ts: 0 };
104
+ }
105
+ }