@ultimabase/live 0.0.0-stage → 0.1.1
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/CHANGELOG.md +9 -0
- package/README.md +4 -2
- package/dist/apollo.d.ts +13 -0
- package/dist/apollo.d.ts.map +1 -0
- package/dist/apollo.js +159 -0
- package/dist/apply.d.ts +26 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/apply.js +64 -0
- package/dist/error.d.ts +6 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +11 -0
- package/dist/fetch.d.ts +17 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +168 -0
- package/dist/guards.d.ts +7 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +5 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +32 -0
- package/package.json +68 -3
- package/src/apollo.ts +214 -0
- package/src/apply.ts +90 -0
- package/src/error.ts +13 -0
- package/src/fetch.ts +194 -0
- package/src/guards.ts +15 -0
- package/src/index.ts +7 -0
- package/src/react.ts +63 -0
package/CHANGELOG.md
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ultimabase/live
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The client of `@ultimabase/realtime`: live lists and rows over Server-Sent Events, an Apollo link for GraphQL subscriptions, and a React hook.
|
|
4
|
+
|
|
5
|
+
Documentation: [Realtime](https://ultimabase.dev/docs/realtime).
|
package/dist/apollo.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ApolloLink } from '@apollo/client';
|
|
2
|
+
/**
|
|
3
|
+
* What grows a live subscription, passed as its `live` context: the link
|
|
4
|
+
* sets `fetchMore`.
|
|
5
|
+
*/
|
|
6
|
+
export type LiveControl = {
|
|
7
|
+
fetchMore?: (options: {
|
|
8
|
+
readonly first: number;
|
|
9
|
+
}) => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
/** The link that follows live subscriptions; other operations pass. */
|
|
12
|
+
export declare function ultimabaseLive(): ApolloLink;
|
|
13
|
+
//# sourceMappingURL=apollo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apollo.d.ts","sourceRoot":"","sources":["../src/apollo.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAS3C;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG;IACzB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAClE,CAAA;AA0LD,uEAAuE;AACvE,wBAAgB,cAAc,IAAI,UAAU,CAM3C"}
|
package/dist/apollo.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ApolloLink } from '@apollo/client';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
import { applyLive } from './apply.js';
|
|
4
|
+
import { LiveError } from './error.js';
|
|
5
|
+
import { isList, isMessage, isRecord } from './guards.js';
|
|
6
|
+
const isControl = (value) => typeof value === 'object' && value !== null;
|
|
7
|
+
/** The root field's key, and the variable its `first` or `take` reads. */
|
|
8
|
+
function rootOf(query) {
|
|
9
|
+
const operation = query.definitions.find((definition) => definition.kind === 'OperationDefinition');
|
|
10
|
+
const field = operation?.selectionSet.selections.find((selection) => selection.kind === 'Field' && selection.name.value !== '__typename');
|
|
11
|
+
if (field === undefined)
|
|
12
|
+
return {};
|
|
13
|
+
const value = field.arguments?.find(({ name }) => name.value === 'first' || name.value === 'take')?.value;
|
|
14
|
+
return {
|
|
15
|
+
key: field.alias?.value ?? field.name.value,
|
|
16
|
+
...(value?.kind === 'Variable' && { grows: value.name.value }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** The rows of `list`, the nodes under a connection. */
|
|
20
|
+
function rowsOf(list) {
|
|
21
|
+
if (Array.isArray(list))
|
|
22
|
+
return list;
|
|
23
|
+
const edges = Reflect.get(list, 'edges');
|
|
24
|
+
if (!Array.isArray(edges))
|
|
25
|
+
return [];
|
|
26
|
+
return edges.map((edge) => isRecord(edge) ? edge['node'] : undefined);
|
|
27
|
+
}
|
|
28
|
+
/** Evicts from `cache` the rows of `list` that `changes` names gone. */
|
|
29
|
+
function evict(cache, list, changes = []) {
|
|
30
|
+
const gone = new Set(changes.flatMap((change) => 'remove' in change && change.gone === true ? [change.remove] : []));
|
|
31
|
+
if (gone.size === 0)
|
|
32
|
+
return;
|
|
33
|
+
for (const row of rowsOf(list)) {
|
|
34
|
+
const { id, __typename } = isRecord(row) ? row : {};
|
|
35
|
+
const entity = typeof id === 'string' && typeof __typename === 'string' && gone.has(id)
|
|
36
|
+
? cache.identify({ __typename, id })
|
|
37
|
+
: undefined;
|
|
38
|
+
if (entity !== undefined)
|
|
39
|
+
cache.evict({ id: entity });
|
|
40
|
+
}
|
|
41
|
+
cache.gc();
|
|
42
|
+
}
|
|
43
|
+
/** Follows `operation`, a live subscription, applying its messages. */
|
|
44
|
+
function followed(operation, forward) {
|
|
45
|
+
const { key, grows } = rootOf(operation.query);
|
|
46
|
+
const { cache } = operation.client;
|
|
47
|
+
const { extensions } = operation;
|
|
48
|
+
const asked = extensions['live'];
|
|
49
|
+
const control = operation.getContext()['live'];
|
|
50
|
+
return new Observable((observer) => {
|
|
51
|
+
let data;
|
|
52
|
+
let revision = 0;
|
|
53
|
+
let token;
|
|
54
|
+
let inner;
|
|
55
|
+
let closed = false;
|
|
56
|
+
const waiting = [];
|
|
57
|
+
const settle = (error) => {
|
|
58
|
+
for (const { resolve, reject } of waiting.splice(0)) {
|
|
59
|
+
if (error === undefined)
|
|
60
|
+
resolve();
|
|
61
|
+
else
|
|
62
|
+
reject(error);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const fail = (error) => {
|
|
66
|
+
inner?.unsubscribe();
|
|
67
|
+
settle(error);
|
|
68
|
+
observer.error(error);
|
|
69
|
+
};
|
|
70
|
+
const received = (result) => {
|
|
71
|
+
const live = result.extensions?.['live'];
|
|
72
|
+
if (key === undefined || !isMessage(live))
|
|
73
|
+
return observer.next(result);
|
|
74
|
+
const whole = 'data' in result ? result.data : undefined;
|
|
75
|
+
if (isRecord(whole)) {
|
|
76
|
+
data = whole;
|
|
77
|
+
revision = live.revision;
|
|
78
|
+
const resume = Reflect.get(live, 'resume');
|
|
79
|
+
if (typeof resume === 'string')
|
|
80
|
+
token = resume;
|
|
81
|
+
settle();
|
|
82
|
+
return observer.next(result);
|
|
83
|
+
}
|
|
84
|
+
const list = data?.[key];
|
|
85
|
+
const next = data !== undefined && live.revision === revision + 1 && isList(list)
|
|
86
|
+
? applyLive(list, live)
|
|
87
|
+
: undefined;
|
|
88
|
+
// Out of step: read the list again.
|
|
89
|
+
if (data === undefined || next === undefined || !isList(list)) {
|
|
90
|
+
data = undefined;
|
|
91
|
+
token = undefined;
|
|
92
|
+
return open();
|
|
93
|
+
}
|
|
94
|
+
revision += 1;
|
|
95
|
+
data = { ...data, [key]: next };
|
|
96
|
+
observer.next({ ...result, data });
|
|
97
|
+
evict(cache, list, live.changes);
|
|
98
|
+
settle();
|
|
99
|
+
};
|
|
100
|
+
function open() {
|
|
101
|
+
inner?.unsubscribe();
|
|
102
|
+
operation.extensions =
|
|
103
|
+
token === undefined
|
|
104
|
+
? extensions
|
|
105
|
+
: {
|
|
106
|
+
...extensions,
|
|
107
|
+
live: { ...(isRecord(asked) && asked), resume: token },
|
|
108
|
+
};
|
|
109
|
+
inner = forward(operation).subscribe({
|
|
110
|
+
next: (result) => {
|
|
111
|
+
try {
|
|
112
|
+
received(result);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
fail(error);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
error: fail,
|
|
119
|
+
complete: () => {
|
|
120
|
+
settle();
|
|
121
|
+
observer.complete();
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (isControl(control)) {
|
|
126
|
+
control.fetchMore = async ({ first: more }) => {
|
|
127
|
+
const shown = grows === undefined ? undefined : operation.variables[grows];
|
|
128
|
+
if (grows === undefined ||
|
|
129
|
+
typeof shown !== 'number' ||
|
|
130
|
+
!Number.isInteger(shown)) {
|
|
131
|
+
throw new LiveError('BAD_USER_INPUT', 'fetchMore grows a live list: its root field needs first or take from a variable.');
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isInteger(more) || more < 1) {
|
|
134
|
+
throw new LiveError('BAD_USER_INPUT', 'first must be a positive integer.');
|
|
135
|
+
}
|
|
136
|
+
operation.variables = { ...operation.variables, [grows]: shown + more };
|
|
137
|
+
if (closed)
|
|
138
|
+
return;
|
|
139
|
+
const grown = new Promise((resolve, reject) => {
|
|
140
|
+
waiting.push({ resolve, reject });
|
|
141
|
+
});
|
|
142
|
+
open();
|
|
143
|
+
return grown;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
open();
|
|
147
|
+
return () => {
|
|
148
|
+
closed = true;
|
|
149
|
+
inner?.unsubscribe();
|
|
150
|
+
settle();
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** The link that follows live subscriptions; other operations pass. */
|
|
155
|
+
export function ultimabaseLive() {
|
|
156
|
+
return new ApolloLink((operation, forward) => operation.operationType === 'subscription'
|
|
157
|
+
? followed(operation, forward)
|
|
158
|
+
: forward(operation));
|
|
159
|
+
}
|
package/dist/apply.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** One change of a live list: a row gone from it, or placed after another. */
|
|
2
|
+
export type LiveChange = {
|
|
3
|
+
readonly remove: string;
|
|
4
|
+
readonly gone?: boolean;
|
|
5
|
+
} | {
|
|
6
|
+
readonly upsert: unknown;
|
|
7
|
+
readonly after: string | null;
|
|
8
|
+
};
|
|
9
|
+
/** A live list's message: SSE's `live` event, GraphQL's `extensions.live`. */
|
|
10
|
+
export type LiveMessage = {
|
|
11
|
+
readonly revision: number;
|
|
12
|
+
readonly changes?: readonly LiveChange[];
|
|
13
|
+
readonly totalCount?: number;
|
|
14
|
+
readonly pageInfo?: unknown;
|
|
15
|
+
};
|
|
16
|
+
/** A live list: a connection, or an array of rows. */
|
|
17
|
+
export type LiveList = readonly unknown[] | {
|
|
18
|
+
readonly [key: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* `list` changed by `message`, a new list; `undefined` when the message
|
|
22
|
+
* names a row the list does not hold, which is out of step: read it again.
|
|
23
|
+
* The caller applies messages in order, each revision one more than the last.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyLive<T extends LiveList>(list: T, message: LiveMessage): T | undefined;
|
|
26
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../src/apply.ts"],"names":[],"mappings":"AAKA,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GACnB;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AAE9D,8EAA8E;AAC9E,MAAM,MAAM,WAAW,GAAG;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,UAAU,EAAE,CAAA;IACxC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC3B,CAAA;AAED,sDAAsD;AACtD,MAAM,MAAM,QAAQ,GAAG,SAAS,OAAO,EAAE,GAAG;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAA;AA2C/E;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,QAAQ,EAC3C,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,WAAW,GAClB,CAAC,GAAG,SAAS,CAmBf"}
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// What a live list's message changes (D-154): rows placed by their id after
|
|
2
|
+
// another's, under a connection's edges or in a plain array of rows.
|
|
3
|
+
import { LiveError } from './error.js';
|
|
4
|
+
import { isRecord } from './guards.js';
|
|
5
|
+
const isRows = (list) => Array.isArray(list);
|
|
6
|
+
/** `row`'s id, the node's under a connection. */
|
|
7
|
+
function idOf(row, connected) {
|
|
8
|
+
const node = connected && isRecord(row) ? row['node'] : row;
|
|
9
|
+
const id = isRecord(node) ? node['id'] : undefined;
|
|
10
|
+
if (typeof id !== 'string') {
|
|
11
|
+
throw new LiveError('BAD_USER_INPUT', "Select the node's id to follow a live list.");
|
|
12
|
+
}
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
15
|
+
/** `rows` changed by `changes`, or `undefined` if they name a row not in it. */
|
|
16
|
+
function placed(rows, changes, connected) {
|
|
17
|
+
const next = [...rows];
|
|
18
|
+
const at = (id) => next.findIndex((row) => idOf(row, connected) === id);
|
|
19
|
+
for (const change of changes) {
|
|
20
|
+
if ('remove' in change) {
|
|
21
|
+
const index = at(change.remove);
|
|
22
|
+
if (index < 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
next.splice(index, 1);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const index = at(idOf(change.upsert, connected));
|
|
28
|
+
if (index >= 0)
|
|
29
|
+
next.splice(index, 1);
|
|
30
|
+
const after = change.after === null ? -1 : at(change.after);
|
|
31
|
+
if (change.after !== null && after < 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
next.splice(after + 1, 0, change.upsert);
|
|
34
|
+
}
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `list` changed by `message`, a new list; `undefined` when the message
|
|
39
|
+
* names a row the list does not hold, which is out of step: read it again.
|
|
40
|
+
* The caller applies messages in order, each revision one more than the last.
|
|
41
|
+
*/
|
|
42
|
+
export function applyLive(list, message) {
|
|
43
|
+
const changes = message.changes ?? [];
|
|
44
|
+
const read = list;
|
|
45
|
+
if (isRows(read)) {
|
|
46
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- an array of T's rows.
|
|
47
|
+
return placed(read, changes, false);
|
|
48
|
+
}
|
|
49
|
+
const connection = read;
|
|
50
|
+
const next = { ...connection };
|
|
51
|
+
const { edges } = connection;
|
|
52
|
+
if (Array.isArray(edges)) {
|
|
53
|
+
const rows = placed(edges, changes, true);
|
|
54
|
+
if (rows === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
next['edges'] = rows;
|
|
57
|
+
}
|
|
58
|
+
if (message.pageInfo !== undefined)
|
|
59
|
+
next['pageInfo'] = message.pageInfo;
|
|
60
|
+
if (message.totalCount !== undefined)
|
|
61
|
+
next['totalCount'] = message.totalCount;
|
|
62
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- T with its fields replaced.
|
|
63
|
+
return next;
|
|
64
|
+
}
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAGA,sDAAsD;AACtD,qBAAa,SAAU,SAAQ,KAAK;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAIxC;CACD"}
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A live read's failure (D-154), with the server's code: its `error` event,
|
|
2
|
+
// its refusal, or a read the client cannot follow.
|
|
3
|
+
/** A live read that failed; it does not reconnect. */
|
|
4
|
+
export class LiveError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'LiveError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { LiveError } from './error.ts';
|
|
2
|
+
/** What a live read sends with each request. */
|
|
3
|
+
export type LiveFetchOptions = {
|
|
4
|
+
readonly headers?: ConstructorParameters<typeof Headers>[0];
|
|
5
|
+
};
|
|
6
|
+
/** A live read: each whole result to its listeners, grown on demand. */
|
|
7
|
+
export type LiveFetch<T> = {
|
|
8
|
+
/** Hands `listener` each result, the current one first; returns what stops it. */
|
|
9
|
+
readonly subscribe: (listener: (result: T) => void, failed?: (error: LiveError) => void) => () => void;
|
|
10
|
+
/** Adds `first` rows to the list; resolves once they arrived. */
|
|
11
|
+
readonly fetchMore: (options: {
|
|
12
|
+
readonly first: number;
|
|
13
|
+
}) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
/** Follows `url`, a live model's list or row, once a listener subscribes. */
|
|
16
|
+
export declare function liveFetch<T = unknown>(url: string, options?: LiveFetchOptions): LiveFetch<T>;
|
|
17
|
+
//# sourceMappingURL=fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAGtC,gDAAgD;AAChD,MAAM,MAAM,gBAAgB,GAAG;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,qBAAqB,CAAC,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;CAC3D,CAAA;AAED,wEAAwE;AACxE,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI;IAC1B,kFAAkF;IAClF,QAAQ,CAAC,SAAS,EAAE,CACnB,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,EAC7B,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,KAC/B,MAAM,IAAI,CAAA;IACf,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1E,CAAA;AAsBD,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,CAAC,GAAG,OAAO,EACpC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,gBAAqB,GAC5B,SAAS,CAAC,CAAC,CAAC,CAgJd"}
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
|
2
|
+
import { applyLive } from './apply.js';
|
|
3
|
+
import { LiveError } from './error.js';
|
|
4
|
+
import { isList, isMessage, isRecord } from './guards.js';
|
|
5
|
+
const firstBackoff = 100;
|
|
6
|
+
const lastBackoff = 5000;
|
|
7
|
+
/** The server's `{ code, message }`, as a `LiveError`. */
|
|
8
|
+
function errorOf(body) {
|
|
9
|
+
if (isRecord(body) &&
|
|
10
|
+
typeof body['code'] === 'string' &&
|
|
11
|
+
typeof body['message'] === 'string') {
|
|
12
|
+
return new LiveError(body['code'], body['message']);
|
|
13
|
+
}
|
|
14
|
+
return new LiveError('INTERNAL', 'The live read failed.');
|
|
15
|
+
}
|
|
16
|
+
/** Follows `url`, a live model's list or row, once a listener subscribes. */
|
|
17
|
+
export function liveFetch(url, options = {}) {
|
|
18
|
+
const at = url.indexOf('?');
|
|
19
|
+
const path = at < 0 ? url : url.slice(0, at);
|
|
20
|
+
const params = new URLSearchParams(at < 0 ? '' : url.slice(at + 1));
|
|
21
|
+
const arg = ['first', 'take'].find((name) => params.has(name));
|
|
22
|
+
const headers = new Headers(options.headers);
|
|
23
|
+
headers.set('accept', 'text/event-stream');
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
const waiting = [];
|
|
26
|
+
let result;
|
|
27
|
+
let revision = 0;
|
|
28
|
+
let token;
|
|
29
|
+
let request;
|
|
30
|
+
let timer;
|
|
31
|
+
let backoff = firstBackoff;
|
|
32
|
+
const settle = (error) => {
|
|
33
|
+
for (const { resolve, reject } of waiting.splice(0)) {
|
|
34
|
+
if (error === undefined)
|
|
35
|
+
resolve();
|
|
36
|
+
else
|
|
37
|
+
reject(error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const halt = () => {
|
|
41
|
+
request?.abort();
|
|
42
|
+
request = undefined;
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
timer = undefined;
|
|
45
|
+
};
|
|
46
|
+
const fail = (error) => {
|
|
47
|
+
halt();
|
|
48
|
+
for (const { failed } of listeners)
|
|
49
|
+
failed?.(error);
|
|
50
|
+
settle(error);
|
|
51
|
+
};
|
|
52
|
+
const emit = (value) => {
|
|
53
|
+
result = { value };
|
|
54
|
+
settle();
|
|
55
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the caller names what its request selects.
|
|
56
|
+
for (const { listener } of listeners)
|
|
57
|
+
listener(value);
|
|
58
|
+
};
|
|
59
|
+
const open = () => {
|
|
60
|
+
halt();
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
request = controller;
|
|
63
|
+
const asked = new URLSearchParams(params);
|
|
64
|
+
if (token !== undefined)
|
|
65
|
+
asked.set('resume', token);
|
|
66
|
+
void read(controller, `${path}?${asked.toString()}`);
|
|
67
|
+
};
|
|
68
|
+
const afresh = () => {
|
|
69
|
+
token = undefined;
|
|
70
|
+
open();
|
|
71
|
+
};
|
|
72
|
+
/** Handles `event`; `false` once the stream is replaced or failed. */
|
|
73
|
+
const handle = ({ event, id, data }) => {
|
|
74
|
+
const body = JSON.parse(data);
|
|
75
|
+
if (event === 'resume' && typeof body === 'string')
|
|
76
|
+
token = body;
|
|
77
|
+
if (event === 'data') {
|
|
78
|
+
revision = Number(id);
|
|
79
|
+
emit(body);
|
|
80
|
+
}
|
|
81
|
+
if (event === 'error') {
|
|
82
|
+
fail(errorOf(body));
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (event !== 'live')
|
|
86
|
+
return true;
|
|
87
|
+
const list = result?.value;
|
|
88
|
+
const next = isMessage(body) && body.revision === revision + 1 && isList(list)
|
|
89
|
+
? applyLive(list, body)
|
|
90
|
+
: undefined;
|
|
91
|
+
// Out of step: read the list again.
|
|
92
|
+
if (next === undefined) {
|
|
93
|
+
afresh();
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
revision += 1;
|
|
97
|
+
emit(next);
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
async function read(controller, href) {
|
|
101
|
+
try {
|
|
102
|
+
const { signal } = controller;
|
|
103
|
+
const response = await fetch(href, { headers, signal });
|
|
104
|
+
if (!response.ok && response.status < 500) {
|
|
105
|
+
const body = await response.json().catch(() => undefined);
|
|
106
|
+
return fail(errorOf(body));
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok)
|
|
109
|
+
await response.body?.cancel();
|
|
110
|
+
const reader = (response.ok ? response.body : null)
|
|
111
|
+
?.pipeThrough(new TextDecoderStream())
|
|
112
|
+
.pipeThrough(new EventSourceParserStream())
|
|
113
|
+
.getReader();
|
|
114
|
+
for (;;) {
|
|
115
|
+
const next = await reader?.read();
|
|
116
|
+
if (next === undefined || next.done || signal.aborted)
|
|
117
|
+
break;
|
|
118
|
+
backoff = firstBackoff;
|
|
119
|
+
if (!handle(next.value))
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof LiveError)
|
|
125
|
+
return fail(error);
|
|
126
|
+
}
|
|
127
|
+
if (request !== controller)
|
|
128
|
+
return;
|
|
129
|
+
// Cut, ended or refused for a while: reconnect with the token.
|
|
130
|
+
timer = setTimeout(open, backoff);
|
|
131
|
+
backoff = Math.min(backoff * 2, lastBackoff);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
subscribe: (listener, failed) => {
|
|
135
|
+
const entry = { listener, failed };
|
|
136
|
+
listeners.add(entry);
|
|
137
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the caller names what its request selects.
|
|
138
|
+
if (result !== undefined)
|
|
139
|
+
listener(result.value);
|
|
140
|
+
if (request === undefined)
|
|
141
|
+
open();
|
|
142
|
+
return () => {
|
|
143
|
+
listeners.delete(entry);
|
|
144
|
+
if (listeners.size > 0)
|
|
145
|
+
return;
|
|
146
|
+
halt();
|
|
147
|
+
settle();
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
fetchMore: async ({ first: more }) => {
|
|
151
|
+
const shown = Number(params.get(arg ?? '') ?? Number.NaN);
|
|
152
|
+
if (arg === undefined || !Number.isInteger(shown)) {
|
|
153
|
+
throw new LiveError('BAD_USER_INPUT', 'fetchMore grows a live list: its URL needs first or take.');
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isInteger(more) || more < 1) {
|
|
156
|
+
throw new LiveError('BAD_USER_INPUT', 'first must be a positive integer.');
|
|
157
|
+
}
|
|
158
|
+
params.set(arg, String(shown + more));
|
|
159
|
+
if (request === undefined)
|
|
160
|
+
return;
|
|
161
|
+
const grown = new Promise((resolve, reject) => {
|
|
162
|
+
waiting.push({ resolve, reject });
|
|
163
|
+
});
|
|
164
|
+
open();
|
|
165
|
+
return grown;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LiveList, LiveMessage } from './apply.ts';
|
|
2
|
+
export declare const isRecord: (value: unknown) => value is {
|
|
3
|
+
readonly [key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export declare const isList: (value: unknown) => value is LiveList;
|
|
6
|
+
export declare const isMessage: (value: unknown) => value is LiveMessage;
|
|
7
|
+
//# sourceMappingURL=guards.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAEvD,eAAO,MAAM,QAAQ,UACb,OAAO,KACZ,KAAK,IAAI;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACD,CAAA;AAE5C,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,KAAK,IAAI,QACT,CAAA;AAExC,eAAO,MAAM,SAAS,UAAW,OAAO,KAAG,KAAK,IAAI,WAGgB,CAAA"}
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
2
|
+
export const isList = (value) => Array.isArray(value) || isRecord(value);
|
|
3
|
+
export const isMessage = (value) => isRecord(value) &&
|
|
4
|
+
typeof value['revision'] === 'number' &&
|
|
5
|
+
(value['changes'] === undefined || Array.isArray(value['changes']));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { LiveChange, LiveList, LiveMessage } from './apply.ts';
|
|
2
|
+
export { applyLive } from './apply.ts';
|
|
3
|
+
export { LiveError } from './error.ts';
|
|
4
|
+
export type { LiveFetch, LiveFetchOptions } from './fetch.ts';
|
|
5
|
+
export { liveFetch } from './fetch.ts';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACtC,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/index.js
ADDED
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DocumentNode, OperationVariables, TypedDocumentNode } from '@apollo/client';
|
|
2
|
+
import { useSubscription } from '@apollo/client/react';
|
|
3
|
+
/** A live subscription's result, and what adds rows to its list. */
|
|
4
|
+
export type LiveSubscriptionResult<TData> = useSubscription.Result<TData> & {
|
|
5
|
+
/** Adds `first` rows to the list; resolves once they arrived. */
|
|
6
|
+
readonly fetchMore: (options: {
|
|
7
|
+
readonly first: number;
|
|
8
|
+
}) => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
/** Follows `subscription`, a live list or row; `fetchMore` grows the list. */
|
|
11
|
+
export declare function useLiveSubscription<TData = unknown, TVariables extends OperationVariables = OperationVariables>(subscription: DocumentNode | TypedDocumentNode<TData, TVariables>, ...[options]: {} extends TVariables ? [options?: useSubscription.Options<TData, TVariables>] : [options: useSubscription.Options<TData, TVariables>]): LiveSubscriptionResult<TData>;
|
|
12
|
+
//# sourceMappingURL=react.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACX,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAKtD,oEAAoE;AACpE,MAAM,MAAM,sBAAsB,CAAC,KAAK,IAAI,eAAe,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG;IAC3E,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1E,CAAA;AAED,8EAA8E;AAC9E,wBAAgB,mBAAmB,CAClC,KAAK,GAAG,OAAO,EACf,UAAU,SAAS,kBAAkB,GAAG,kBAAkB,EAE1D,YAAY,EAAE,YAAY,GAAG,iBAAiB,CAAC,KAAK,EAAE,UAAU,CAAC,EAEjE,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,UAAU,GAChC,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC,GACtD,CAAC,OAAO,EAAE,eAAe,CAAC,OAAO,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC,GACtD,sBAAsB,CAAC,KAAK,CAAC,CAkC/B"}
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useSubscription } from '@apollo/client/react';
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { LiveError } from './error.js';
|
|
4
|
+
/** Follows `subscription`, a live list or row; `fetchMore` grows the list. */
|
|
5
|
+
export function useLiveSubscription(subscription,
|
|
6
|
+
// As `useSubscription`: its options once a variable is required.
|
|
7
|
+
...[options]) {
|
|
8
|
+
const [live] = useState(() => {
|
|
9
|
+
const control = {};
|
|
10
|
+
const fetchMore = async (more) => {
|
|
11
|
+
if (control.fetchMore === undefined) {
|
|
12
|
+
throw new LiveError('BAD_USER_INPUT', 'fetchMore grows a live subscription once it runs.');
|
|
13
|
+
}
|
|
14
|
+
return control.fetchMore(more);
|
|
15
|
+
};
|
|
16
|
+
return { control, fetchMore };
|
|
17
|
+
});
|
|
18
|
+
// The rest parameter's type holds the variables a subscription requires.
|
|
19
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TypeScript cannot follow it through the spread.
|
|
20
|
+
const asked = {
|
|
21
|
+
...options,
|
|
22
|
+
// Each hook holds its own subscription, which its control grows.
|
|
23
|
+
context: {
|
|
24
|
+
...options?.context,
|
|
25
|
+
live: live.control,
|
|
26
|
+
queryDeduplication: false,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const subscribed = useSubscription;
|
|
30
|
+
const result = subscribed(subscription, asked);
|
|
31
|
+
return useMemo(() => ({ ...result, fetchMore: live.fetchMore }), [result, live]);
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/live",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The UltimaBase live client: live lists and rows over SSE, an Apollo link and a React hook.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/realtime",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/live"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./dist/index.js",
|
|
15
|
+
"./apollo": "./dist/apollo.js",
|
|
16
|
+
"./react": "./dist/react.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/test"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/index.js",
|
|
30
|
+
"./apollo": "./dist/apollo.js",
|
|
31
|
+
"./react": "./dist/react.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"eventsource-parser": "4.1.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@apollo/client": "^4.0.0",
|
|
39
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
40
|
+
"rxjs": "^7.3.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"@apollo/client": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"rxjs": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@apollo/client": "4.3.3",
|
|
55
|
+
"@types/react": "19.3.0",
|
|
56
|
+
"@types/react-dom": "19.3.0",
|
|
57
|
+
"@ultimabase/graphql": "0.1.1",
|
|
58
|
+
"@ultimabase/http": "0.1.1",
|
|
59
|
+
"@ultimabase/realtime": "0.1.1",
|
|
60
|
+
"@ultimabase/rest": "0.1.1",
|
|
61
|
+
"graphql": "17.0.2",
|
|
62
|
+
"graphql-ws": "6.4.1",
|
|
63
|
+
"happy-dom": "21.0.1",
|
|
64
|
+
"react": "19.3.1",
|
|
65
|
+
"react-dom": "19.3.1",
|
|
66
|
+
"rxjs": "7.8.2",
|
|
67
|
+
"ultimabase": "0.1.1",
|
|
68
|
+
"vite": "8.3.4",
|
|
69
|
+
"vitest": "5.0.5"
|
|
70
|
+
}
|
|
6
71
|
}
|
package/src/apollo.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Apollo's link to live subscriptions (D-154), placed before the WebSocket
|
|
2
|
+
// link: it applies each message of a live list to the result it holds and
|
|
3
|
+
// hands Apollo the whole result. It grows the list by completing it and
|
|
4
|
+
// subscribing again with the token, in one tick, so the socket stays open.
|
|
5
|
+
import type { ApolloCache } from '@apollo/client'
|
|
6
|
+
import { ApolloLink } from '@apollo/client'
|
|
7
|
+
import type { DocumentNode, FieldNode, OperationDefinitionNode } from 'graphql'
|
|
8
|
+
import type { Subscription } from 'rxjs'
|
|
9
|
+
import { Observable } from 'rxjs'
|
|
10
|
+
import type { LiveChange, LiveList } from './apply.ts'
|
|
11
|
+
import { applyLive } from './apply.ts'
|
|
12
|
+
import { LiveError } from './error.ts'
|
|
13
|
+
import { isList, isMessage, isRecord } from './guards.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What grows a live subscription, passed as its `live` context: the link
|
|
17
|
+
* sets `fetchMore`.
|
|
18
|
+
*/
|
|
19
|
+
export type LiveControl = {
|
|
20
|
+
fetchMore?: (options: { readonly first: number }) => Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Data = { readonly [key: string]: unknown }
|
|
24
|
+
type Waiter = {
|
|
25
|
+
readonly resolve: () => void
|
|
26
|
+
readonly reject: (error: unknown) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isControl = (value: unknown): value is LiveControl =>
|
|
30
|
+
typeof value === 'object' && value !== null
|
|
31
|
+
|
|
32
|
+
/** The root field's key, and the variable its `first` or `take` reads. */
|
|
33
|
+
function rootOf(query: DocumentNode): {
|
|
34
|
+
readonly key?: string
|
|
35
|
+
readonly grows?: string
|
|
36
|
+
} {
|
|
37
|
+
const operation = query.definitions.find(
|
|
38
|
+
(definition): definition is OperationDefinitionNode =>
|
|
39
|
+
definition.kind === 'OperationDefinition',
|
|
40
|
+
)
|
|
41
|
+
const field = operation?.selectionSet.selections.find(
|
|
42
|
+
(selection): selection is FieldNode =>
|
|
43
|
+
selection.kind === 'Field' && selection.name.value !== '__typename',
|
|
44
|
+
)
|
|
45
|
+
if (field === undefined) return {}
|
|
46
|
+
const value = field.arguments?.find(
|
|
47
|
+
({ name }) => name.value === 'first' || name.value === 'take',
|
|
48
|
+
)?.value
|
|
49
|
+
return {
|
|
50
|
+
key: field.alias?.value ?? field.name.value,
|
|
51
|
+
...(value?.kind === 'Variable' && { grows: value.name.value }),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The rows of `list`, the nodes under a connection. */
|
|
56
|
+
function rowsOf(list: LiveList): readonly unknown[] {
|
|
57
|
+
if (Array.isArray(list)) return list
|
|
58
|
+
const edges: unknown = Reflect.get(list, 'edges')
|
|
59
|
+
if (!Array.isArray(edges)) return []
|
|
60
|
+
return edges.map((edge: unknown) =>
|
|
61
|
+
isRecord(edge) ? edge['node'] : undefined,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Evicts from `cache` the rows of `list` that `changes` names gone. */
|
|
66
|
+
function evict(
|
|
67
|
+
cache: ApolloCache,
|
|
68
|
+
list: LiveList,
|
|
69
|
+
changes: readonly LiveChange[] = [],
|
|
70
|
+
) {
|
|
71
|
+
const gone = new Set(
|
|
72
|
+
changes.flatMap((change) =>
|
|
73
|
+
'remove' in change && change.gone === true ? [change.remove] : [],
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
if (gone.size === 0) return
|
|
77
|
+
for (const row of rowsOf(list)) {
|
|
78
|
+
const { id, __typename } = isRecord(row) ? row : {}
|
|
79
|
+
const entity =
|
|
80
|
+
typeof id === 'string' && typeof __typename === 'string' && gone.has(id)
|
|
81
|
+
? cache.identify({ __typename, id })
|
|
82
|
+
: undefined
|
|
83
|
+
if (entity !== undefined) cache.evict({ id: entity })
|
|
84
|
+
}
|
|
85
|
+
cache.gc()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Follows `operation`, a live subscription, applying its messages. */
|
|
89
|
+
function followed(
|
|
90
|
+
operation: ApolloLink.Operation,
|
|
91
|
+
forward: ApolloLink.ForwardFunction,
|
|
92
|
+
): Observable<ApolloLink.Result> {
|
|
93
|
+
const { key, grows } = rootOf(operation.query)
|
|
94
|
+
const { cache } = operation.client
|
|
95
|
+
const { extensions } = operation
|
|
96
|
+
const asked: unknown = extensions['live']
|
|
97
|
+
const control: unknown = operation.getContext()['live']
|
|
98
|
+
return new Observable((observer) => {
|
|
99
|
+
let data: Data | undefined
|
|
100
|
+
let revision = 0
|
|
101
|
+
let token: string | undefined
|
|
102
|
+
let inner: Subscription | undefined
|
|
103
|
+
let closed = false
|
|
104
|
+
const waiting: Waiter[] = []
|
|
105
|
+
const settle = (error?: unknown) => {
|
|
106
|
+
for (const { resolve, reject } of waiting.splice(0)) {
|
|
107
|
+
if (error === undefined) resolve()
|
|
108
|
+
else reject(error)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const fail = (error: unknown) => {
|
|
112
|
+
inner?.unsubscribe()
|
|
113
|
+
settle(error)
|
|
114
|
+
observer.error(error)
|
|
115
|
+
}
|
|
116
|
+
const received = (result: ApolloLink.Result) => {
|
|
117
|
+
const live: unknown = result.extensions?.['live']
|
|
118
|
+
if (key === undefined || !isMessage(live)) return observer.next(result)
|
|
119
|
+
const whole: unknown = 'data' in result ? result.data : undefined
|
|
120
|
+
if (isRecord(whole)) {
|
|
121
|
+
data = whole
|
|
122
|
+
revision = live.revision
|
|
123
|
+
const resume: unknown = Reflect.get(live, 'resume')
|
|
124
|
+
if (typeof resume === 'string') token = resume
|
|
125
|
+
settle()
|
|
126
|
+
return observer.next(result)
|
|
127
|
+
}
|
|
128
|
+
const list = data?.[key]
|
|
129
|
+
const next =
|
|
130
|
+
data !== undefined && live.revision === revision + 1 && isList(list)
|
|
131
|
+
? applyLive(list, live)
|
|
132
|
+
: undefined
|
|
133
|
+
// Out of step: read the list again.
|
|
134
|
+
if (data === undefined || next === undefined || !isList(list)) {
|
|
135
|
+
data = undefined
|
|
136
|
+
token = undefined
|
|
137
|
+
return open()
|
|
138
|
+
}
|
|
139
|
+
revision += 1
|
|
140
|
+
data = { ...data, [key]: next }
|
|
141
|
+
observer.next({ ...result, data })
|
|
142
|
+
evict(cache, list, live.changes)
|
|
143
|
+
settle()
|
|
144
|
+
}
|
|
145
|
+
function open() {
|
|
146
|
+
inner?.unsubscribe()
|
|
147
|
+
operation.extensions =
|
|
148
|
+
token === undefined
|
|
149
|
+
? extensions
|
|
150
|
+
: {
|
|
151
|
+
...extensions,
|
|
152
|
+
live: { ...(isRecord(asked) && asked), resume: token },
|
|
153
|
+
}
|
|
154
|
+
inner = forward(operation).subscribe({
|
|
155
|
+
next: (result) => {
|
|
156
|
+
try {
|
|
157
|
+
received(result)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
fail(error)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
error: fail,
|
|
163
|
+
complete: () => {
|
|
164
|
+
settle()
|
|
165
|
+
observer.complete()
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
if (isControl(control)) {
|
|
170
|
+
control.fetchMore = async ({ first: more }) => {
|
|
171
|
+
const shown: unknown =
|
|
172
|
+
grows === undefined ? undefined : operation.variables[grows]
|
|
173
|
+
if (
|
|
174
|
+
grows === undefined ||
|
|
175
|
+
typeof shown !== 'number' ||
|
|
176
|
+
!Number.isInteger(shown)
|
|
177
|
+
) {
|
|
178
|
+
throw new LiveError(
|
|
179
|
+
'BAD_USER_INPUT',
|
|
180
|
+
'fetchMore grows a live list: its root field needs first or take from a variable.',
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isInteger(more) || more < 1) {
|
|
184
|
+
throw new LiveError(
|
|
185
|
+
'BAD_USER_INPUT',
|
|
186
|
+
'first must be a positive integer.',
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
operation.variables = { ...operation.variables, [grows]: shown + more }
|
|
190
|
+
if (closed) return
|
|
191
|
+
const grown = new Promise<void>((resolve, reject) => {
|
|
192
|
+
waiting.push({ resolve, reject })
|
|
193
|
+
})
|
|
194
|
+
open()
|
|
195
|
+
return grown
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
open()
|
|
199
|
+
return () => {
|
|
200
|
+
closed = true
|
|
201
|
+
inner?.unsubscribe()
|
|
202
|
+
settle()
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The link that follows live subscriptions; other operations pass. */
|
|
208
|
+
export function ultimabaseLive(): ApolloLink {
|
|
209
|
+
return new ApolloLink((operation, forward) =>
|
|
210
|
+
operation.operationType === 'subscription'
|
|
211
|
+
? followed(operation, forward)
|
|
212
|
+
: forward(operation),
|
|
213
|
+
)
|
|
214
|
+
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// What a live list's message changes (D-154): rows placed by their id after
|
|
2
|
+
// another's, under a connection's edges or in a plain array of rows.
|
|
3
|
+
import { LiveError } from './error.ts'
|
|
4
|
+
import { isRecord } from './guards.ts'
|
|
5
|
+
|
|
6
|
+
/** One change of a live list: a row gone from it, or placed after another. */
|
|
7
|
+
export type LiveChange =
|
|
8
|
+
| { readonly remove: string; readonly gone?: boolean }
|
|
9
|
+
| { readonly upsert: unknown; readonly after: string | null }
|
|
10
|
+
|
|
11
|
+
/** A live list's message: SSE's `live` event, GraphQL's `extensions.live`. */
|
|
12
|
+
export type LiveMessage = {
|
|
13
|
+
readonly revision: number
|
|
14
|
+
readonly changes?: readonly LiveChange[]
|
|
15
|
+
readonly totalCount?: number
|
|
16
|
+
readonly pageInfo?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A live list: a connection, or an array of rows. */
|
|
20
|
+
export type LiveList = readonly unknown[] | { readonly [key: string]: unknown }
|
|
21
|
+
|
|
22
|
+
const isRows = (list: LiveList): list is readonly unknown[] =>
|
|
23
|
+
Array.isArray(list)
|
|
24
|
+
|
|
25
|
+
/** `row`'s id, the node's under a connection. */
|
|
26
|
+
function idOf(row: unknown, connected: boolean): string {
|
|
27
|
+
const node = connected && isRecord(row) ? row['node'] : row
|
|
28
|
+
const id = isRecord(node) ? node['id'] : undefined
|
|
29
|
+
if (typeof id !== 'string') {
|
|
30
|
+
throw new LiveError(
|
|
31
|
+
'BAD_USER_INPUT',
|
|
32
|
+
"Select the node's id to follow a live list.",
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return id
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `rows` changed by `changes`, or `undefined` if they name a row not in it. */
|
|
39
|
+
function placed(
|
|
40
|
+
rows: readonly unknown[],
|
|
41
|
+
changes: readonly LiveChange[],
|
|
42
|
+
connected: boolean,
|
|
43
|
+
): unknown[] | undefined {
|
|
44
|
+
const next = [...rows]
|
|
45
|
+
const at = (id: string) =>
|
|
46
|
+
next.findIndex((row) => idOf(row, connected) === id)
|
|
47
|
+
for (const change of changes) {
|
|
48
|
+
if ('remove' in change) {
|
|
49
|
+
const index = at(change.remove)
|
|
50
|
+
if (index < 0) return undefined
|
|
51
|
+
next.splice(index, 1)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const index = at(idOf(change.upsert, connected))
|
|
55
|
+
if (index >= 0) next.splice(index, 1)
|
|
56
|
+
const after = change.after === null ? -1 : at(change.after)
|
|
57
|
+
if (change.after !== null && after < 0) return undefined
|
|
58
|
+
next.splice(after + 1, 0, change.upsert)
|
|
59
|
+
}
|
|
60
|
+
return next
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `list` changed by `message`, a new list; `undefined` when the message
|
|
65
|
+
* names a row the list does not hold, which is out of step: read it again.
|
|
66
|
+
* The caller applies messages in order, each revision one more than the last.
|
|
67
|
+
*/
|
|
68
|
+
export function applyLive<T extends LiveList>(
|
|
69
|
+
list: T,
|
|
70
|
+
message: LiveMessage,
|
|
71
|
+
): T | undefined {
|
|
72
|
+
const changes = message.changes ?? []
|
|
73
|
+
const read: LiveList = list
|
|
74
|
+
if (isRows(read)) {
|
|
75
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- an array of T's rows.
|
|
76
|
+
return placed(read, changes, false) as T | undefined
|
|
77
|
+
}
|
|
78
|
+
const connection = read
|
|
79
|
+
const next = { ...connection }
|
|
80
|
+
const { edges } = connection
|
|
81
|
+
if (Array.isArray(edges)) {
|
|
82
|
+
const rows = placed(edges, changes, true)
|
|
83
|
+
if (rows === undefined) return undefined
|
|
84
|
+
next['edges'] = rows
|
|
85
|
+
}
|
|
86
|
+
if (message.pageInfo !== undefined) next['pageInfo'] = message.pageInfo
|
|
87
|
+
if (message.totalCount !== undefined) next['totalCount'] = message.totalCount
|
|
88
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- T with its fields replaced.
|
|
89
|
+
return next as T
|
|
90
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A live read's failure (D-154), with the server's code: its `error` event,
|
|
2
|
+
// its refusal, or a read the client cannot follow.
|
|
3
|
+
|
|
4
|
+
/** A live read that failed; it does not reconnect. */
|
|
5
|
+
export class LiveError extends Error {
|
|
6
|
+
readonly code: string
|
|
7
|
+
|
|
8
|
+
constructor(code: string, message: string) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'LiveError'
|
|
11
|
+
this.code = code
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// A live list or row over Server-Sent Events (D-154), read with `fetch`,
|
|
2
|
+
// which sends the caller's headers. One stream serves every listener; it
|
|
3
|
+
// reconnects with its token, backing off, and grows by reopening with it.
|
|
4
|
+
import type { EventSourceMessage } from 'eventsource-parser/stream'
|
|
5
|
+
import { EventSourceParserStream } from 'eventsource-parser/stream'
|
|
6
|
+
import { applyLive } from './apply.ts'
|
|
7
|
+
import { LiveError } from './error.ts'
|
|
8
|
+
import { isList, isMessage, isRecord } from './guards.ts'
|
|
9
|
+
|
|
10
|
+
/** What a live read sends with each request. */
|
|
11
|
+
export type LiveFetchOptions = {
|
|
12
|
+
readonly headers?: ConstructorParameters<typeof Headers>[0]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A live read: each whole result to its listeners, grown on demand. */
|
|
16
|
+
export type LiveFetch<T> = {
|
|
17
|
+
/** Hands `listener` each result, the current one first; returns what stops it. */
|
|
18
|
+
readonly subscribe: (
|
|
19
|
+
listener: (result: T) => void,
|
|
20
|
+
failed?: (error: LiveError) => void,
|
|
21
|
+
) => () => void
|
|
22
|
+
/** Adds `first` rows to the list; resolves once they arrived. */
|
|
23
|
+
readonly fetchMore: (options: { readonly first: number }) => Promise<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Listener<T> = {
|
|
27
|
+
readonly listener: (result: T) => void
|
|
28
|
+
readonly failed: ((error: LiveError) => void) | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const firstBackoff = 100
|
|
32
|
+
const lastBackoff = 5000
|
|
33
|
+
|
|
34
|
+
/** The server's `{ code, message }`, as a `LiveError`. */
|
|
35
|
+
function errorOf(body: unknown): LiveError {
|
|
36
|
+
if (
|
|
37
|
+
isRecord(body) &&
|
|
38
|
+
typeof body['code'] === 'string' &&
|
|
39
|
+
typeof body['message'] === 'string'
|
|
40
|
+
) {
|
|
41
|
+
return new LiveError(body['code'], body['message'])
|
|
42
|
+
}
|
|
43
|
+
return new LiveError('INTERNAL', 'The live read failed.')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Follows `url`, a live model's list or row, once a listener subscribes. */
|
|
47
|
+
export function liveFetch<T = unknown>(
|
|
48
|
+
url: string,
|
|
49
|
+
options: LiveFetchOptions = {},
|
|
50
|
+
): LiveFetch<T> {
|
|
51
|
+
const at = url.indexOf('?')
|
|
52
|
+
const path = at < 0 ? url : url.slice(0, at)
|
|
53
|
+
const params = new URLSearchParams(at < 0 ? '' : url.slice(at + 1))
|
|
54
|
+
const arg = ['first', 'take'].find((name) => params.has(name))
|
|
55
|
+
const headers = new Headers(options.headers)
|
|
56
|
+
headers.set('accept', 'text/event-stream')
|
|
57
|
+
const listeners = new Set<Listener<T>>()
|
|
58
|
+
const waiting: { resolve: () => void; reject: (error: LiveError) => void }[] =
|
|
59
|
+
[]
|
|
60
|
+
let result: { readonly value: unknown } | undefined
|
|
61
|
+
let revision = 0
|
|
62
|
+
let token: string | undefined
|
|
63
|
+
let request: AbortController | undefined
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
65
|
+
let backoff = firstBackoff
|
|
66
|
+
|
|
67
|
+
const settle = (error?: LiveError) => {
|
|
68
|
+
for (const { resolve, reject } of waiting.splice(0)) {
|
|
69
|
+
if (error === undefined) resolve()
|
|
70
|
+
else reject(error)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const halt = () => {
|
|
74
|
+
request?.abort()
|
|
75
|
+
request = undefined
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
timer = undefined
|
|
78
|
+
}
|
|
79
|
+
const fail = (error: LiveError) => {
|
|
80
|
+
halt()
|
|
81
|
+
for (const { failed } of listeners) failed?.(error)
|
|
82
|
+
settle(error)
|
|
83
|
+
}
|
|
84
|
+
const emit = (value: unknown) => {
|
|
85
|
+
result = { value }
|
|
86
|
+
settle()
|
|
87
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the caller names what its request selects.
|
|
88
|
+
for (const { listener } of listeners) listener(value as T)
|
|
89
|
+
}
|
|
90
|
+
const open = () => {
|
|
91
|
+
halt()
|
|
92
|
+
const controller = new AbortController()
|
|
93
|
+
request = controller
|
|
94
|
+
const asked = new URLSearchParams(params)
|
|
95
|
+
if (token !== undefined) asked.set('resume', token)
|
|
96
|
+
void read(controller, `${path}?${asked.toString()}`)
|
|
97
|
+
}
|
|
98
|
+
const afresh = () => {
|
|
99
|
+
token = undefined
|
|
100
|
+
open()
|
|
101
|
+
}
|
|
102
|
+
/** Handles `event`; `false` once the stream is replaced or failed. */
|
|
103
|
+
const handle = ({ event, id, data }: EventSourceMessage): boolean => {
|
|
104
|
+
const body: unknown = JSON.parse(data)
|
|
105
|
+
if (event === 'resume' && typeof body === 'string') token = body
|
|
106
|
+
if (event === 'data') {
|
|
107
|
+
revision = Number(id)
|
|
108
|
+
emit(body)
|
|
109
|
+
}
|
|
110
|
+
if (event === 'error') {
|
|
111
|
+
fail(errorOf(body))
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
if (event !== 'live') return true
|
|
115
|
+
const list = result?.value
|
|
116
|
+
const next =
|
|
117
|
+
isMessage(body) && body.revision === revision + 1 && isList(list)
|
|
118
|
+
? applyLive(list, body)
|
|
119
|
+
: undefined
|
|
120
|
+
// Out of step: read the list again.
|
|
121
|
+
if (next === undefined) {
|
|
122
|
+
afresh()
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
revision += 1
|
|
126
|
+
emit(next)
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
async function read(controller: AbortController, href: string) {
|
|
130
|
+
try {
|
|
131
|
+
const { signal } = controller
|
|
132
|
+
const response = await fetch(href, { headers, signal })
|
|
133
|
+
if (!response.ok && response.status < 500) {
|
|
134
|
+
const body: unknown = await response.json().catch(() => undefined)
|
|
135
|
+
return fail(errorOf(body))
|
|
136
|
+
}
|
|
137
|
+
if (!response.ok) await response.body?.cancel()
|
|
138
|
+
const reader = (response.ok ? response.body : null)
|
|
139
|
+
?.pipeThrough(new TextDecoderStream())
|
|
140
|
+
.pipeThrough(new EventSourceParserStream())
|
|
141
|
+
.getReader()
|
|
142
|
+
for (;;) {
|
|
143
|
+
const next = await reader?.read()
|
|
144
|
+
if (next === undefined || next.done || signal.aborted) break
|
|
145
|
+
backoff = firstBackoff
|
|
146
|
+
if (!handle(next.value)) return
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error instanceof LiveError) return fail(error)
|
|
150
|
+
}
|
|
151
|
+
if (request !== controller) return
|
|
152
|
+
// Cut, ended or refused for a while: reconnect with the token.
|
|
153
|
+
timer = setTimeout(open, backoff)
|
|
154
|
+
backoff = Math.min(backoff * 2, lastBackoff)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
subscribe: (listener, failed) => {
|
|
159
|
+
const entry = { listener, failed }
|
|
160
|
+
listeners.add(entry)
|
|
161
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the caller names what its request selects.
|
|
162
|
+
if (result !== undefined) listener(result.value as T)
|
|
163
|
+
if (request === undefined) open()
|
|
164
|
+
return () => {
|
|
165
|
+
listeners.delete(entry)
|
|
166
|
+
if (listeners.size > 0) return
|
|
167
|
+
halt()
|
|
168
|
+
settle()
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
fetchMore: async ({ first: more }) => {
|
|
172
|
+
const shown = Number(params.get(arg ?? '') ?? Number.NaN)
|
|
173
|
+
if (arg === undefined || !Number.isInteger(shown)) {
|
|
174
|
+
throw new LiveError(
|
|
175
|
+
'BAD_USER_INPUT',
|
|
176
|
+
'fetchMore grows a live list: its URL needs first or take.',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
if (!Number.isInteger(more) || more < 1) {
|
|
180
|
+
throw new LiveError(
|
|
181
|
+
'BAD_USER_INPUT',
|
|
182
|
+
'first must be a positive integer.',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
params.set(arg, String(shown + more))
|
|
186
|
+
if (request === undefined) return
|
|
187
|
+
const grown = new Promise<void>((resolve, reject) => {
|
|
188
|
+
waiting.push({ resolve, reject })
|
|
189
|
+
})
|
|
190
|
+
open()
|
|
191
|
+
return grown
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// What the client reads from a server, checked before it is used.
|
|
2
|
+
import type { LiveList, LiveMessage } from './apply.ts'
|
|
3
|
+
|
|
4
|
+
export const isRecord = (
|
|
5
|
+
value: unknown,
|
|
6
|
+
): value is { readonly [key: string]: unknown } =>
|
|
7
|
+
typeof value === 'object' && value !== null
|
|
8
|
+
|
|
9
|
+
export const isList = (value: unknown): value is LiveList =>
|
|
10
|
+
Array.isArray(value) || isRecord(value)
|
|
11
|
+
|
|
12
|
+
export const isMessage = (value: unknown): value is LiveMessage =>
|
|
13
|
+
isRecord(value) &&
|
|
14
|
+
typeof value['revision'] === 'number' &&
|
|
15
|
+
(value['changes'] === undefined || Array.isArray(value['changes']))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The live reads' client (D-154): a list's messages applied, and a live read
|
|
2
|
+
// over Server-Sent Events.
|
|
3
|
+
export type { LiveChange, LiveList, LiveMessage } from './apply.ts'
|
|
4
|
+
export { applyLive } from './apply.ts'
|
|
5
|
+
export { LiveError } from './error.ts'
|
|
6
|
+
export type { LiveFetch, LiveFetchOptions } from './fetch.ts'
|
|
7
|
+
export { liveFetch } from './fetch.ts'
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// React's hook to a live subscription (D-154): Apollo's `useSubscription`
|
|
2
|
+
// through `ultimabaseLive`, with what grows its list.
|
|
3
|
+
import type {
|
|
4
|
+
DocumentNode,
|
|
5
|
+
OperationVariables,
|
|
6
|
+
TypedDocumentNode,
|
|
7
|
+
} from '@apollo/client'
|
|
8
|
+
import { useSubscription } from '@apollo/client/react'
|
|
9
|
+
import { useMemo, useState } from 'react'
|
|
10
|
+
import type { LiveControl } from './apollo.ts'
|
|
11
|
+
import { LiveError } from './error.ts'
|
|
12
|
+
|
|
13
|
+
/** A live subscription's result, and what adds rows to its list. */
|
|
14
|
+
export type LiveSubscriptionResult<TData> = useSubscription.Result<TData> & {
|
|
15
|
+
/** Adds `first` rows to the list; resolves once they arrived. */
|
|
16
|
+
readonly fetchMore: (options: { readonly first: number }) => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Follows `subscription`, a live list or row; `fetchMore` grows the list. */
|
|
20
|
+
export function useLiveSubscription<
|
|
21
|
+
TData = unknown,
|
|
22
|
+
TVariables extends OperationVariables = OperationVariables,
|
|
23
|
+
>(
|
|
24
|
+
subscription: DocumentNode | TypedDocumentNode<TData, TVariables>,
|
|
25
|
+
// As `useSubscription`: its options once a variable is required.
|
|
26
|
+
...[options]: {} extends TVariables
|
|
27
|
+
? [options?: useSubscription.Options<TData, TVariables>]
|
|
28
|
+
: [options: useSubscription.Options<TData, TVariables>]
|
|
29
|
+
): LiveSubscriptionResult<TData> {
|
|
30
|
+
const [live] = useState(() => {
|
|
31
|
+
const control: LiveControl = {}
|
|
32
|
+
const fetchMore = async (more: { readonly first: number }) => {
|
|
33
|
+
if (control.fetchMore === undefined) {
|
|
34
|
+
throw new LiveError(
|
|
35
|
+
'BAD_USER_INPUT',
|
|
36
|
+
'fetchMore grows a live subscription once it runs.',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return control.fetchMore(more)
|
|
40
|
+
}
|
|
41
|
+
return { control, fetchMore }
|
|
42
|
+
})
|
|
43
|
+
// The rest parameter's type holds the variables a subscription requires.
|
|
44
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TypeScript cannot follow it through the spread.
|
|
45
|
+
const asked = {
|
|
46
|
+
...options,
|
|
47
|
+
// Each hook holds its own subscription, which its control grows.
|
|
48
|
+
context: {
|
|
49
|
+
...options?.context,
|
|
50
|
+
live: live.control,
|
|
51
|
+
queryDeduplication: false,
|
|
52
|
+
},
|
|
53
|
+
} as useSubscription.Options<TData, TVariables>
|
|
54
|
+
const subscribed: (
|
|
55
|
+
document: DocumentNode | TypedDocumentNode<TData, TVariables>,
|
|
56
|
+
options: useSubscription.Options<TData, TVariables>,
|
|
57
|
+
) => useSubscription.Result<TData> = useSubscription
|
|
58
|
+
const result = subscribed(subscription, asked)
|
|
59
|
+
return useMemo(
|
|
60
|
+
() => ({ ...result, fetchMore: live.fetchMore }),
|
|
61
|
+
[result, live],
|
|
62
|
+
)
|
|
63
|
+
}
|