@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 ADDED
@@ -0,0 +1,9 @@
1
+ # @ultimabase/live
2
+
3
+ ## 0.1.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.1.0
8
+
9
+ No changes in this release.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/live
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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).
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /** A live read that failed; it does not reconnect. */
2
+ export declare class LiveError extends Error {
3
+ readonly code: string;
4
+ constructor(code: string, message: string);
5
+ }
6
+ //# sourceMappingURL=error.d.ts.map
@@ -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
+ }
@@ -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
+ }
@@ -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']));
@@ -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
@@ -0,0 +1,3 @@
1
+ export { applyLive } from './apply.js';
2
+ export { LiveError } from './error.js';
3
+ export { liveFetch } from './fetch.js';
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }