@cccteam/resource-firestore 0.0.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/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @cccteam/resource-firestore
|
|
2
|
+
|
|
3
|
+
The Firestore change feed for live pages over [`@cccteam/resource`](../resource/README.md).
|
|
4
|
+
A page that asks for a live list or a live row stays current without polling: the server
|
|
5
|
+
writes one change document per subscribed user into that user's change set, and this feed
|
|
6
|
+
is the one listener per tab that reads it and hands each change to the client's live
|
|
7
|
+
session, which refetches the row or list the change names. It is the only package of the
|
|
8
|
+
three that depends on the firebase JS SDK; the client stays free of it.
|
|
9
|
+
|
|
10
|
+
What a page opts into, what it costs, and what it never does are in the client's README
|
|
11
|
+
under "Live pages". This package adds nothing to that contract; it is one way of delivering
|
|
12
|
+
the changes, and the client is written so another could replace it.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @cccteam/resource @cccteam/resource-firestore firebase
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`firebase` is a peer dependency: the application installs it once, so one copy of the SDK
|
|
19
|
+
serves the feed and anything else the application does with Firebase.
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
Build the feed and hand it to the client's live session once the session is authenticated;
|
|
24
|
+
stop the session at logout, before the session itself is logged out.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { firestoreChangeFeed } from '@cccteam/resource-firestore';
|
|
28
|
+
|
|
29
|
+
const api = createApi({ baseUrl: '/api' });
|
|
30
|
+
await api.live.start(firestoreChangeFeed());
|
|
31
|
+
// ...
|
|
32
|
+
await api.live.stop();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
An Angular application provides it instead, and `AuthService` starts and stops it:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { CHANGE_FEED } from '@cccteam/resource-angular/types';
|
|
39
|
+
import { firestoreChangeFeed } from '@cccteam/resource-firestore';
|
|
40
|
+
|
|
41
|
+
providers: [{ provide: CHANGE_FEED, useFactory: () => firestoreChangeFeed() }];
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What the feed does
|
|
45
|
+
|
|
46
|
+
`start(identity)` receives what the server minted for the session's principal at
|
|
47
|
+
`GET <prefix>/live/token`: the GCP project, the Firestore database, and either the emulator
|
|
48
|
+
or a custom token. It initializes a Firebase app of its own for the project (and the web API
|
|
49
|
+
key, in production), opens the database, and connects as the user: against the emulator,
|
|
50
|
+
with the SDK's mock user token for the uid (`connectFirestoreEmulator(db, host, port,
|
|
51
|
+
{ mockUserToken: { sub: uid, user_id: uid } })`); in production, by signing the custom token
|
|
52
|
+
in through Firebase Auth. Then it listens to `users/{uid}/changes`, ordered by `at` and, once
|
|
53
|
+
a change has been seen, bounded to the ones after it.
|
|
54
|
+
|
|
55
|
+
The first snapshot of a listener primes the last change seen and emits nothing. At start, the
|
|
56
|
+
documents already there predate the page's own first fetch; after an error, the resync the
|
|
57
|
+
recovery emits already refetches everything. Each later document becomes one `ChangeEvent`:
|
|
58
|
+
its `kind` (`row`, `list`, or `resource`), `resource`, `key`, `domain`, and `deleted` as the
|
|
59
|
+
document carries them, and `at` as unix microseconds in decimal, the version the refetch
|
|
60
|
+
carries. A document rewritten within its second (the server coalesces writes to one target
|
|
61
|
+
per second) moves forward and is one more event; a document expiring out of the result set
|
|
62
|
+
is not.
|
|
63
|
+
|
|
64
|
+
A quiet stretch is not evidence that anything was missed: the listener keeps its connection
|
|
65
|
+
and resumes after a drop, and the change documents live ten minutes, so a change is lost only
|
|
66
|
+
when the listener was down or suspended for longer than that. The feed therefore emits
|
|
67
|
+
`resync` on two occasions and no other. When a listener reports an error it delivers nothing
|
|
68
|
+
more, so the feed starts another after a wait that doubles with each failure (one second, up
|
|
69
|
+
to thirty), bounded by the last change seen, and the first snapshot of that listener is a
|
|
70
|
+
resync. And when the page becomes visible again (`visibilitychange` to visible, or `pageshow`
|
|
71
|
+
from the back-forward cache) after having been hidden longer than the change documents live,
|
|
72
|
+
since browsers throttle or drop a background page's connection, the feed emits a resync. The
|
|
73
|
+
live session refetches every live row and list by a fresh seed either way, and does the same
|
|
74
|
+
itself when it starts.
|
|
75
|
+
|
|
76
|
+
`stop()` releases the listener and deletes the app it initialized. Nothing is remembered
|
|
77
|
+
across a stop: a later start listens from the beginning again.
|
|
78
|
+
|
|
79
|
+
The security rules the server leg ships let a user read their own change set and nothing
|
|
80
|
+
else, and let no client write; the feed never writes.
|
|
81
|
+
|
|
82
|
+
## What is configurable
|
|
83
|
+
|
|
84
|
+
`firestoreChangeFeed(options)` takes `resyncAfterHidden` (how long a page may be hidden before
|
|
85
|
+
its return is a resync; the change document's life by default), `retryDelay` and
|
|
86
|
+
`maxRetryDelay` (the restart wait), `now` (the clock), and `visibility` and `page` (where the
|
|
87
|
+
document's visibility and the page's hide and show are heard; the document and the window by
|
|
88
|
+
default). Every one has a default, and a spec is the usual reason to turn one.
|
|
89
|
+
|
|
90
|
+
## Testing
|
|
91
|
+
|
|
92
|
+
The specs are written against `bun:test` and run with `bun test projects/resource-firestore/`
|
|
93
|
+
(the trailing slash keeps bun off the Angular library beside it); `bun run typecheck:firestore`
|
|
94
|
+
compiles them with tsc over `projects/resource-firestore/tsconfig.spec.json`.
|
|
95
|
+
`bun run test:firestore` runs both. The specs replace the three SDK modules (`firebase/app`,
|
|
96
|
+
`firebase/auth`, `firebase/firestore`) with recording stand-ins through `mock.module`, drive
|
|
97
|
+
the listener's snapshots and errors by hand, stand in for the document and the window to hide
|
|
98
|
+
and show the page, and read the events and resyncs back; no Firestore, emulated or real, is
|
|
99
|
+
involved.
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { liveChangeTtl } from '@cccteam/resource';
|
|
2
|
+
import { initializeApp, deleteApp } from 'firebase/app';
|
|
3
|
+
import { signInWithCustomToken, getAuth } from 'firebase/auth';
|
|
4
|
+
import { getFirestore, connectFirestoreEmulator, collection, query, where, orderBy, onSnapshot } from 'firebase/firestore';
|
|
5
|
+
|
|
6
|
+
let instances = 0;
|
|
7
|
+
/**
|
|
8
|
+
* The Firestore change feed: one listener per tab over the user's change set,
|
|
9
|
+
* `users/{uid}/changes`, ordered by `at` and bounded below by the last change seen. It
|
|
10
|
+
* connects with the identity the server minted (`GET <prefix>/live/token`): the project,
|
|
11
|
+
* the database, and either the emulator with a mock user token for the uid, or a custom
|
|
12
|
+
* token signed in through Firebase Auth. Each document becomes one ChangeEvent with `at`
|
|
13
|
+
* as unix microseconds.
|
|
14
|
+
*
|
|
15
|
+
* The first snapshot of a listener primes the last change seen and emits nothing: at
|
|
16
|
+
* start the backlog predates the page's own first fetch, and after an error the
|
|
17
|
+
* `resync` the recovery emits already refetches everything. The listener keeps its
|
|
18
|
+
* connection and resumes after a drop, so a quiet stretch is not evidence of anything
|
|
19
|
+
* missed; a change is lost only when the listener was down or suspended longer than the
|
|
20
|
+
* change documents live. So the feed emits `resync` in two cases and no other: a failed
|
|
21
|
+
* listener (which delivers nothing more) is started again with a growing wait and its
|
|
22
|
+
* first snapshot is a resync, and a page that becomes visible again after being hidden
|
|
23
|
+
* longer than the change document's life is a resync, since browsers throttle or drop a
|
|
24
|
+
* background page's connection. The live session's own start refetches everything too.
|
|
25
|
+
*/
|
|
26
|
+
class FirestoreChangeFeed {
|
|
27
|
+
resyncAfterHidden;
|
|
28
|
+
retryDelay;
|
|
29
|
+
maxRetryDelay;
|
|
30
|
+
now;
|
|
31
|
+
visibility;
|
|
32
|
+
page;
|
|
33
|
+
changeListeners = new Set();
|
|
34
|
+
resyncListeners = new Set();
|
|
35
|
+
app;
|
|
36
|
+
db;
|
|
37
|
+
uid = '';
|
|
38
|
+
unsubscribe;
|
|
39
|
+
retry;
|
|
40
|
+
failures = 0;
|
|
41
|
+
lastSeen;
|
|
42
|
+
/**
|
|
43
|
+
* The ids of the documents already delivered (or primed) at the `lastSeen` instant,
|
|
44
|
+
* replaced whenever it advances. One commit writes its row and list documents with one
|
|
45
|
+
* server timestamp, and a listener may deliver them in two snapshots, so a document at
|
|
46
|
+
* the last instant seen is new unless its id is here.
|
|
47
|
+
*/
|
|
48
|
+
seenAtLastSeen = new Set();
|
|
49
|
+
/** When the page went hidden, while it is; undefined while visible. */
|
|
50
|
+
hiddenSince;
|
|
51
|
+
onVisibilityChange = () => {
|
|
52
|
+
if (this.visibility?.visibilityState === 'hidden') {
|
|
53
|
+
this.hidden();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.visible();
|
|
57
|
+
};
|
|
58
|
+
onPageHide = () => this.hidden();
|
|
59
|
+
onPageShow = (event) => {
|
|
60
|
+
if (event.persisted) {
|
|
61
|
+
this.visible();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
constructor(options = {}) {
|
|
65
|
+
this.resyncAfterHidden = options.resyncAfterHidden ?? liveChangeTtl;
|
|
66
|
+
this.retryDelay = options.retryDelay ?? 1_000;
|
|
67
|
+
this.maxRetryDelay = options.maxRetryDelay ?? 30_000;
|
|
68
|
+
this.now = options.now ?? Date.now;
|
|
69
|
+
this.visibility = options.visibility ?? (typeof document === 'undefined' ? undefined : document);
|
|
70
|
+
this.page = options.page ?? (typeof window === 'undefined' ? undefined : window);
|
|
71
|
+
}
|
|
72
|
+
/** Whether a listener is held: the feed is between start and stop. */
|
|
73
|
+
get started() {
|
|
74
|
+
return this.app !== undefined;
|
|
75
|
+
}
|
|
76
|
+
async start(identity) {
|
|
77
|
+
if (this.app) {
|
|
78
|
+
await this.stop();
|
|
79
|
+
}
|
|
80
|
+
const app = initializeApp({ projectId: identity.project, apiKey: identity.apiKey || undefined }, `cccteam-resource-live-${++instances}`);
|
|
81
|
+
const db = identity.database ? getFirestore(app, identity.database) : getFirestore(app);
|
|
82
|
+
if (identity.emulator) {
|
|
83
|
+
const { host, port } = hostAndPort(identity.emulator);
|
|
84
|
+
connectFirestoreEmulator(db, host, port, { mockUserToken: { sub: identity.uid, user_id: identity.uid } });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
await signInWithCustomToken(getAuth(app), identity.token);
|
|
88
|
+
}
|
|
89
|
+
this.app = app;
|
|
90
|
+
this.db = db;
|
|
91
|
+
this.uid = identity.uid;
|
|
92
|
+
this.lastSeen = undefined;
|
|
93
|
+
this.seenAtLastSeen = new Set();
|
|
94
|
+
this.failures = 0;
|
|
95
|
+
this.hiddenSince = this.visibility?.visibilityState === 'hidden' ? this.now() : undefined;
|
|
96
|
+
this.visibility?.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
97
|
+
this.page?.addEventListener('pagehide', this.onPageHide);
|
|
98
|
+
this.page?.addEventListener('pageshow', this.onPageShow);
|
|
99
|
+
this.listen(false);
|
|
100
|
+
}
|
|
101
|
+
async stop() {
|
|
102
|
+
if (this.retry !== undefined) {
|
|
103
|
+
clearTimeout(this.retry);
|
|
104
|
+
this.retry = undefined;
|
|
105
|
+
}
|
|
106
|
+
this.visibility?.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
107
|
+
this.page?.removeEventListener('pagehide', this.onPageHide);
|
|
108
|
+
this.page?.removeEventListener('pageshow', this.onPageShow);
|
|
109
|
+
this.hiddenSince = undefined;
|
|
110
|
+
this.unsubscribe?.();
|
|
111
|
+
this.unsubscribe = undefined;
|
|
112
|
+
const app = this.app;
|
|
113
|
+
this.app = undefined;
|
|
114
|
+
this.db = undefined;
|
|
115
|
+
this.uid = '';
|
|
116
|
+
this.lastSeen = undefined;
|
|
117
|
+
this.seenAtLastSeen = new Set();
|
|
118
|
+
if (app) {
|
|
119
|
+
try {
|
|
120
|
+
await deleteApp(app);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// The app is being discarded either way.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
onChange(listener) {
|
|
128
|
+
this.changeListeners.add(listener);
|
|
129
|
+
return () => {
|
|
130
|
+
this.changeListeners.delete(listener);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
onResync(listener) {
|
|
134
|
+
this.resyncListeners.add(listener);
|
|
135
|
+
return () => {
|
|
136
|
+
this.resyncListeners.delete(listener);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The page went hidden: the clock starts, once. */
|
|
140
|
+
hidden() {
|
|
141
|
+
this.hiddenSince ??= this.now();
|
|
142
|
+
}
|
|
143
|
+
/** The page is visible again: hidden longer than the change documents live, everything is refetched. */
|
|
144
|
+
visible() {
|
|
145
|
+
const since = this.hiddenSince;
|
|
146
|
+
this.hiddenSince = undefined;
|
|
147
|
+
if (since !== undefined && this.app && this.now() - since > this.resyncAfterHidden) {
|
|
148
|
+
this.emitResync();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Starts the listener over the change set from the last change seen; `recovering` says an error ended the one before. */
|
|
152
|
+
listen(recovering) {
|
|
153
|
+
const db = this.db;
|
|
154
|
+
if (!db) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const changes = collection(db, 'users', this.uid, 'changes');
|
|
158
|
+
const bounded = this.lastSeen
|
|
159
|
+
? query(changes, where('at', '>', this.lastSeen), orderBy('at'))
|
|
160
|
+
: query(changes, orderBy('at'));
|
|
161
|
+
let primed = false;
|
|
162
|
+
this.unsubscribe = onSnapshot(bounded, (snapshot) => {
|
|
163
|
+
this.failures = 0;
|
|
164
|
+
if (!primed) {
|
|
165
|
+
primed = true;
|
|
166
|
+
this.prime(snapshot);
|
|
167
|
+
if (recovering) {
|
|
168
|
+
this.emitResync();
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.deliver(snapshot);
|
|
173
|
+
}, () => {
|
|
174
|
+
// A listener that reported an error delivers nothing more: start another after a wait.
|
|
175
|
+
this.unsubscribe = undefined;
|
|
176
|
+
this.scheduleRestart();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
scheduleRestart() {
|
|
180
|
+
if (!this.app || this.retry !== undefined) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const delay = Math.min(this.maxRetryDelay, this.retryDelay * 2 ** this.failures);
|
|
184
|
+
this.failures++;
|
|
185
|
+
this.retry = setTimeout(() => {
|
|
186
|
+
this.retry = undefined;
|
|
187
|
+
this.listen(true);
|
|
188
|
+
}, delay);
|
|
189
|
+
}
|
|
190
|
+
/** The first snapshot of a listener: the latest change in it is the last seen, its documents at that instant are seen, and nothing is emitted. */
|
|
191
|
+
prime(snapshot) {
|
|
192
|
+
for (const doc of snapshot.docs) {
|
|
193
|
+
const change = toChange(doc.data());
|
|
194
|
+
if (change) {
|
|
195
|
+
this.advance(doc.id, change.at);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A later snapshot: the documents added or moved past the last change seen are events,
|
|
201
|
+
* in `at` order, and so is a document at that very instant not delivered yet.
|
|
202
|
+
*/
|
|
203
|
+
deliver(snapshot) {
|
|
204
|
+
const changes = [];
|
|
205
|
+
for (const entry of snapshot.docChanges()) {
|
|
206
|
+
if (entry.type === 'removed') {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const change = toChange(entry.doc.data());
|
|
210
|
+
if (change && this.unseen(entry.doc.id, change.at)) {
|
|
211
|
+
changes.push({ id: entry.doc.id, change });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
changes.sort((a, b) => compare(a.change.at, b.change.at));
|
|
215
|
+
for (const { id, change } of changes) {
|
|
216
|
+
this.advance(id, change.at);
|
|
217
|
+
for (const listener of [...this.changeListeners]) {
|
|
218
|
+
listener(change.event);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Whether a document at `at` has not been delivered: later than the last seen, or at that instant with an id not yet seen. */
|
|
223
|
+
unseen(id, at) {
|
|
224
|
+
if (this.lastSeen === undefined) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
const order = compare(at, this.lastSeen);
|
|
228
|
+
return order > 0 || (order === 0 && !this.seenAtLastSeen.has(id));
|
|
229
|
+
}
|
|
230
|
+
/** Marks a document seen: a later instant replaces the last seen and its set of ids; the same instant adds the id. */
|
|
231
|
+
advance(id, at) {
|
|
232
|
+
const order = this.lastSeen === undefined ? 1 : compare(at, this.lastSeen);
|
|
233
|
+
if (order > 0) {
|
|
234
|
+
this.lastSeen = at;
|
|
235
|
+
this.seenAtLastSeen = new Set([id]);
|
|
236
|
+
}
|
|
237
|
+
else if (order === 0) {
|
|
238
|
+
this.seenAtLastSeen.add(id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
emitResync() {
|
|
242
|
+
for (const listener of [...this.resyncListeners]) {
|
|
243
|
+
listener();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** The Firestore change feed over the firebase JS SDK. Hand it to `client.live.start`. */
|
|
248
|
+
function firestoreChangeFeed(options) {
|
|
249
|
+
return new FirestoreChangeFeed(options);
|
|
250
|
+
}
|
|
251
|
+
/** `host:port` as the identity carries the emulator, the host bracketed when it is an IPv6 address. */
|
|
252
|
+
function hostAndPort(address) {
|
|
253
|
+
const mark = address.lastIndexOf(':');
|
|
254
|
+
if (mark === -1) {
|
|
255
|
+
throw new Error(`the emulator address "${address}" has no port`);
|
|
256
|
+
}
|
|
257
|
+
const host = address.slice(0, mark).replace(/^\[(.*)\]$/, '$1');
|
|
258
|
+
const port = Number(address.slice(mark + 1));
|
|
259
|
+
if (host === '' || !Number.isInteger(port) || port <= 0) {
|
|
260
|
+
throw new Error(`the emulator address "${address}" is not host:port`);
|
|
261
|
+
}
|
|
262
|
+
return { host, port };
|
|
263
|
+
}
|
|
264
|
+
/** A Firestore timestamp as unix microseconds in decimal: the version a refetch carries. */
|
|
265
|
+
function microseconds(at) {
|
|
266
|
+
return (BigInt(at.seconds) * 1000000n + BigInt(Math.floor(at.nanoseconds / 1_000))).toString();
|
|
267
|
+
}
|
|
268
|
+
function isTimestamp(value) {
|
|
269
|
+
return (typeof value === 'object' &&
|
|
270
|
+
value !== null &&
|
|
271
|
+
typeof value.seconds === 'number' &&
|
|
272
|
+
typeof value.nanoseconds === 'number');
|
|
273
|
+
}
|
|
274
|
+
function compare(a, b) {
|
|
275
|
+
return a.seconds === b.seconds ? a.nanoseconds - b.nanoseconds : a.seconds - b.seconds;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* One change document as an event: `kind`, `resource`, and a resolved `at` are required
|
|
279
|
+
* (a document whose server timestamp has not resolved, or of a shape this feed does not
|
|
280
|
+
* know, is skipped); `key`, `domain`, and `deleted` ride along when present.
|
|
281
|
+
*/
|
|
282
|
+
function toChange(data) {
|
|
283
|
+
const kind = data['kind'];
|
|
284
|
+
const resource = data['resource'];
|
|
285
|
+
const at = data['at'];
|
|
286
|
+
if ((kind !== 'row' && kind !== 'list' && kind !== 'resource') || typeof resource !== 'string' || !isTimestamp(at)) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const event = { kind, resource, at: microseconds(at) };
|
|
290
|
+
if (typeof data['key'] === 'string') {
|
|
291
|
+
event.key = data['key'];
|
|
292
|
+
}
|
|
293
|
+
if (typeof data['domain'] === 'string') {
|
|
294
|
+
event.domain = data['domain'];
|
|
295
|
+
}
|
|
296
|
+
if (typeof data['deleted'] === 'boolean') {
|
|
297
|
+
event.deleted = data['deleted'];
|
|
298
|
+
}
|
|
299
|
+
return { event, at };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Generated bundle index. Do not edit.
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
export { FirestoreChangeFeed, firestoreChangeFeed, hostAndPort, microseconds };
|
|
307
|
+
//# sourceMappingURL=cccteam-resource-firestore.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cccteam-resource-firestore.mjs","sources":["../../../projects/resource-firestore/src/firestore-feed.ts","../../../projects/resource-firestore/cccteam-resource-firestore.ts"],"sourcesContent":["import { ChangeEvent, ChangeFeed, LiveIdentity, liveChangeTtl, PageEvents } from '@cccteam/resource';\nimport { deleteApp, FirebaseApp, initializeApp } from 'firebase/app';\nimport { getAuth, signInWithCustomToken } from 'firebase/auth';\nimport {\n collection,\n connectFirestoreEmulator,\n DocumentData,\n Firestore,\n getFirestore,\n onSnapshot,\n orderBy,\n query,\n QuerySnapshot,\n Unsubscribe,\n where,\n} from 'firebase/firestore';\n\n/** The document's visibility as the feed reads it; the document, in a browser. */\nexport interface VisibilityEvents {\n readonly visibilityState: string;\n addEventListener(type: string, listener: () => void): void;\n removeEventListener(type: string, listener: () => void): void;\n}\n\n/** The knobs of the feed an application or a spec may turn. Every one has a default. */\nexport interface FirestoreFeedOptions {\n /**\n * A page that was hidden longer than this resyncs when it becomes visible again, since\n * a browser throttles or drops a background page's connection and the change documents\n * from that time may have expired unseen; the change document's life (liveChangeTtl) by\n * default.\n */\n resyncAfterHidden?: number;\n /** The wait before a failed listener is started again; doubles each failure up to maxRetryDelay. */\n retryDelay?: number;\n maxRetryDelay?: number;\n /** The clock, in milliseconds; Date.now by default. */\n now?: () => number;\n /** Where visibilitychange is heard; the document by default, nothing outside a browser. */\n visibility?: VisibilityEvents;\n /** Where pagehide and pageshow are heard; the window by default, nothing outside a browser. */\n page?: PageEvents;\n}\n\n/** A Firestore timestamp as the change document carries it; read structurally, so a spec needs no SDK class. */\ninterface TimestampLike {\n seconds: number;\n nanoseconds: number;\n}\n\ninterface Change {\n event: ChangeEvent;\n at: TimestampLike;\n}\n\nlet instances = 0;\n\n/**\n * The Firestore change feed: one listener per tab over the user's change set,\n * `users/{uid}/changes`, ordered by `at` and bounded below by the last change seen. It\n * connects with the identity the server minted (`GET <prefix>/live/token`): the project,\n * the database, and either the emulator with a mock user token for the uid, or a custom\n * token signed in through Firebase Auth. Each document becomes one ChangeEvent with `at`\n * as unix microseconds.\n *\n * The first snapshot of a listener primes the last change seen and emits nothing: at\n * start the backlog predates the page's own first fetch, and after an error the\n * `resync` the recovery emits already refetches everything. The listener keeps its\n * connection and resumes after a drop, so a quiet stretch is not evidence of anything\n * missed; a change is lost only when the listener was down or suspended longer than the\n * change documents live. So the feed emits `resync` in two cases and no other: a failed\n * listener (which delivers nothing more) is started again with a growing wait and its\n * first snapshot is a resync, and a page that becomes visible again after being hidden\n * longer than the change document's life is a resync, since browsers throttle or drop a\n * background page's connection. The live session's own start refetches everything too.\n */\nexport class FirestoreChangeFeed implements ChangeFeed {\n private readonly resyncAfterHidden: number;\n private readonly retryDelay: number;\n private readonly maxRetryDelay: number;\n private readonly now: () => number;\n private readonly visibility: VisibilityEvents | undefined;\n private readonly page: PageEvents | undefined;\n private readonly changeListeners = new Set<(event: ChangeEvent) => void>();\n private readonly resyncListeners = new Set<() => void>();\n private app: FirebaseApp | undefined;\n private db: Firestore | undefined;\n private uid = '';\n private unsubscribe: Unsubscribe | undefined;\n private retry: ReturnType<typeof setTimeout> | undefined;\n private failures = 0;\n private lastSeen: TimestampLike | undefined;\n /**\n * The ids of the documents already delivered (or primed) at the `lastSeen` instant,\n * replaced whenever it advances. One commit writes its row and list documents with one\n * server timestamp, and a listener may deliver them in two snapshots, so a document at\n * the last instant seen is new unless its id is here.\n */\n private seenAtLastSeen = new Set<string>();\n /** When the page went hidden, while it is; undefined while visible. */\n private hiddenSince: number | undefined;\n private readonly onVisibilityChange = (): void => {\n if (this.visibility?.visibilityState === 'hidden') {\n this.hidden();\n return;\n }\n this.visible();\n };\n private readonly onPageHide = (): void => this.hidden();\n private readonly onPageShow = (event: { persisted?: boolean }): void => {\n if (event.persisted) {\n this.visible();\n }\n };\n\n constructor(options: FirestoreFeedOptions = {}) {\n this.resyncAfterHidden = options.resyncAfterHidden ?? liveChangeTtl;\n this.retryDelay = options.retryDelay ?? 1_000;\n this.maxRetryDelay = options.maxRetryDelay ?? 30_000;\n this.now = options.now ?? Date.now;\n this.visibility = options.visibility ?? (typeof document === 'undefined' ? undefined : document);\n this.page = options.page ?? (typeof window === 'undefined' ? undefined : window);\n }\n\n /** Whether a listener is held: the feed is between start and stop. */\n get started(): boolean {\n return this.app !== undefined;\n }\n\n async start(identity: LiveIdentity): Promise<void> {\n if (this.app) {\n await this.stop();\n }\n const app = initializeApp(\n { projectId: identity.project, apiKey: identity.apiKey || undefined },\n `cccteam-resource-live-${++instances}`,\n );\n const db = identity.database ? getFirestore(app, identity.database) : getFirestore(app);\n if (identity.emulator) {\n const { host, port } = hostAndPort(identity.emulator);\n connectFirestoreEmulator(db, host, port, { mockUserToken: { sub: identity.uid, user_id: identity.uid } });\n } else {\n await signInWithCustomToken(getAuth(app), identity.token);\n }\n this.app = app;\n this.db = db;\n this.uid = identity.uid;\n this.lastSeen = undefined;\n this.seenAtLastSeen = new Set();\n this.failures = 0;\n this.hiddenSince = this.visibility?.visibilityState === 'hidden' ? this.now() : undefined;\n this.visibility?.addEventListener('visibilitychange', this.onVisibilityChange);\n this.page?.addEventListener('pagehide', this.onPageHide);\n this.page?.addEventListener('pageshow', this.onPageShow);\n this.listen(false);\n }\n\n async stop(): Promise<void> {\n if (this.retry !== undefined) {\n clearTimeout(this.retry);\n this.retry = undefined;\n }\n this.visibility?.removeEventListener('visibilitychange', this.onVisibilityChange);\n this.page?.removeEventListener('pagehide', this.onPageHide);\n this.page?.removeEventListener('pageshow', this.onPageShow);\n this.hiddenSince = undefined;\n this.unsubscribe?.();\n this.unsubscribe = undefined;\n const app = this.app;\n this.app = undefined;\n this.db = undefined;\n this.uid = '';\n this.lastSeen = undefined;\n this.seenAtLastSeen = new Set();\n if (app) {\n try {\n await deleteApp(app);\n } catch {\n // The app is being discarded either way.\n }\n }\n }\n\n onChange(listener: (event: ChangeEvent) => void): () => void {\n this.changeListeners.add(listener);\n return () => {\n this.changeListeners.delete(listener);\n };\n }\n\n onResync(listener: () => void): () => void {\n this.resyncListeners.add(listener);\n return () => {\n this.resyncListeners.delete(listener);\n };\n }\n\n /** The page went hidden: the clock starts, once. */\n private hidden(): void {\n this.hiddenSince ??= this.now();\n }\n\n /** The page is visible again: hidden longer than the change documents live, everything is refetched. */\n private visible(): void {\n const since = this.hiddenSince;\n this.hiddenSince = undefined;\n if (since !== undefined && this.app && this.now() - since > this.resyncAfterHidden) {\n this.emitResync();\n }\n }\n\n /** Starts the listener over the change set from the last change seen; `recovering` says an error ended the one before. */\n private listen(recovering: boolean): void {\n const db = this.db;\n if (!db) {\n return;\n }\n const changes = collection(db, 'users', this.uid, 'changes');\n const bounded = this.lastSeen\n ? query(changes, where('at', '>', this.lastSeen), orderBy('at'))\n : query(changes, orderBy('at'));\n let primed = false;\n this.unsubscribe = onSnapshot(\n bounded,\n (snapshot) => {\n this.failures = 0;\n if (!primed) {\n primed = true;\n this.prime(snapshot);\n if (recovering) {\n this.emitResync();\n }\n return;\n }\n this.deliver(snapshot);\n },\n () => {\n // A listener that reported an error delivers nothing more: start another after a wait.\n this.unsubscribe = undefined;\n this.scheduleRestart();\n },\n );\n }\n\n private scheduleRestart(): void {\n if (!this.app || this.retry !== undefined) {\n return;\n }\n const delay = Math.min(this.maxRetryDelay, this.retryDelay * 2 ** this.failures);\n this.failures++;\n this.retry = setTimeout(() => {\n this.retry = undefined;\n this.listen(true);\n }, delay);\n }\n\n /** The first snapshot of a listener: the latest change in it is the last seen, its documents at that instant are seen, and nothing is emitted. */\n private prime(snapshot: QuerySnapshot<DocumentData>): void {\n for (const doc of snapshot.docs) {\n const change = toChange(doc.data());\n if (change) {\n this.advance(doc.id, change.at);\n }\n }\n }\n\n /**\n * A later snapshot: the documents added or moved past the last change seen are events,\n * in `at` order, and so is a document at that very instant not delivered yet.\n */\n private deliver(snapshot: QuerySnapshot<DocumentData>): void {\n const changes: { id: string; change: Change }[] = [];\n for (const entry of snapshot.docChanges()) {\n if (entry.type === 'removed') {\n continue;\n }\n const change = toChange(entry.doc.data());\n if (change && this.unseen(entry.doc.id, change.at)) {\n changes.push({ id: entry.doc.id, change });\n }\n }\n changes.sort((a, b) => compare(a.change.at, b.change.at));\n for (const { id, change } of changes) {\n this.advance(id, change.at);\n for (const listener of [...this.changeListeners]) {\n listener(change.event);\n }\n }\n }\n\n /** Whether a document at `at` has not been delivered: later than the last seen, or at that instant with an id not yet seen. */\n private unseen(id: string, at: TimestampLike): boolean {\n if (this.lastSeen === undefined) {\n return true;\n }\n const order = compare(at, this.lastSeen);\n return order > 0 || (order === 0 && !this.seenAtLastSeen.has(id));\n }\n\n /** Marks a document seen: a later instant replaces the last seen and its set of ids; the same instant adds the id. */\n private advance(id: string, at: TimestampLike): void {\n const order = this.lastSeen === undefined ? 1 : compare(at, this.lastSeen);\n if (order > 0) {\n this.lastSeen = at;\n this.seenAtLastSeen = new Set([id]);\n } else if (order === 0) {\n this.seenAtLastSeen.add(id);\n }\n }\n\n private emitResync(): void {\n for (const listener of [...this.resyncListeners]) {\n listener();\n }\n }\n}\n\n/** The Firestore change feed over the firebase JS SDK. Hand it to `client.live.start`. */\nexport function firestoreChangeFeed(options?: FirestoreFeedOptions): ChangeFeed {\n return new FirestoreChangeFeed(options);\n}\n\n/** `host:port` as the identity carries the emulator, the host bracketed when it is an IPv6 address. */\nexport function hostAndPort(address: string): { host: string; port: number } {\n const mark = address.lastIndexOf(':');\n if (mark === -1) {\n throw new Error(`the emulator address \"${address}\" has no port`);\n }\n const host = address.slice(0, mark).replace(/^\\[(.*)\\]$/, '$1');\n const port = Number(address.slice(mark + 1));\n if (host === '' || !Number.isInteger(port) || port <= 0) {\n throw new Error(`the emulator address \"${address}\" is not host:port`);\n }\n return { host, port };\n}\n\n/** A Firestore timestamp as unix microseconds in decimal: the version a refetch carries. */\nexport function microseconds(at: TimestampLike): string {\n return (BigInt(at.seconds) * 1_000_000n + BigInt(Math.floor(at.nanoseconds / 1_000))).toString();\n}\n\nfunction isTimestamp(value: unknown): value is TimestampLike {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as TimestampLike).seconds === 'number' &&\n typeof (value as TimestampLike).nanoseconds === 'number'\n );\n}\n\nfunction compare(a: TimestampLike, b: TimestampLike): number {\n return a.seconds === b.seconds ? a.nanoseconds - b.nanoseconds : a.seconds - b.seconds;\n}\n\n/**\n * One change document as an event: `kind`, `resource`, and a resolved `at` are required\n * (a document whose server timestamp has not resolved, or of a shape this feed does not\n * know, is skipped); `key`, `domain`, and `deleted` ride along when present.\n */\nfunction toChange(data: DocumentData): Change | undefined {\n const kind = data['kind'];\n const resource = data['resource'];\n const at = data['at'];\n if ((kind !== 'row' && kind !== 'list' && kind !== 'resource') || typeof resource !== 'string' || !isTimestamp(at)) {\n return undefined;\n }\n const event: ChangeEvent = { kind, resource, at: microseconds(at) };\n if (typeof data['key'] === 'string') {\n event.key = data['key'];\n }\n if (typeof data['domain'] === 'string') {\n event.domain = data['domain'];\n }\n if (typeof data['deleted'] === 'boolean') {\n event.deleted = data['deleted'];\n }\n return { event, at };\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cccteam/resource-firestore",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The Firestore change feed for live pages over @cccteam/resource",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/cccteam/ccc-lib.git",
|
|
8
|
+
"directory": "projects/resource-firestore"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@cccteam/resource": "^0.0.3",
|
|
12
|
+
"firebase": "^12.19.0"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"tslib": "^2.3.0"
|
|
16
|
+
},
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"module": "fesm2022/cccteam-resource-firestore.mjs",
|
|
19
|
+
"typings": "types/cccteam-resource-firestore.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
"./package.json": {
|
|
22
|
+
"default": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./types/cccteam-resource-firestore.d.ts",
|
|
26
|
+
"default": "./fesm2022/cccteam-resource-firestore.mjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"type": "module"
|
|
30
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ChangeFeed, PageEvents, LiveIdentity, ChangeEvent } from '@cccteam/resource';
|
|
2
|
+
|
|
3
|
+
/** The document's visibility as the feed reads it; the document, in a browser. */
|
|
4
|
+
interface VisibilityEvents {
|
|
5
|
+
readonly visibilityState: string;
|
|
6
|
+
addEventListener(type: string, listener: () => void): void;
|
|
7
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
8
|
+
}
|
|
9
|
+
/** The knobs of the feed an application or a spec may turn. Every one has a default. */
|
|
10
|
+
interface FirestoreFeedOptions {
|
|
11
|
+
/**
|
|
12
|
+
* A page that was hidden longer than this resyncs when it becomes visible again, since
|
|
13
|
+
* a browser throttles or drops a background page's connection and the change documents
|
|
14
|
+
* from that time may have expired unseen; the change document's life (liveChangeTtl) by
|
|
15
|
+
* default.
|
|
16
|
+
*/
|
|
17
|
+
resyncAfterHidden?: number;
|
|
18
|
+
/** The wait before a failed listener is started again; doubles each failure up to maxRetryDelay. */
|
|
19
|
+
retryDelay?: number;
|
|
20
|
+
maxRetryDelay?: number;
|
|
21
|
+
/** The clock, in milliseconds; Date.now by default. */
|
|
22
|
+
now?: () => number;
|
|
23
|
+
/** Where visibilitychange is heard; the document by default, nothing outside a browser. */
|
|
24
|
+
visibility?: VisibilityEvents;
|
|
25
|
+
/** Where pagehide and pageshow are heard; the window by default, nothing outside a browser. */
|
|
26
|
+
page?: PageEvents;
|
|
27
|
+
}
|
|
28
|
+
/** A Firestore timestamp as the change document carries it; read structurally, so a spec needs no SDK class. */
|
|
29
|
+
interface TimestampLike {
|
|
30
|
+
seconds: number;
|
|
31
|
+
nanoseconds: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Firestore change feed: one listener per tab over the user's change set,
|
|
35
|
+
* `users/{uid}/changes`, ordered by `at` and bounded below by the last change seen. It
|
|
36
|
+
* connects with the identity the server minted (`GET <prefix>/live/token`): the project,
|
|
37
|
+
* the database, and either the emulator with a mock user token for the uid, or a custom
|
|
38
|
+
* token signed in through Firebase Auth. Each document becomes one ChangeEvent with `at`
|
|
39
|
+
* as unix microseconds.
|
|
40
|
+
*
|
|
41
|
+
* The first snapshot of a listener primes the last change seen and emits nothing: at
|
|
42
|
+
* start the backlog predates the page's own first fetch, and after an error the
|
|
43
|
+
* `resync` the recovery emits already refetches everything. The listener keeps its
|
|
44
|
+
* connection and resumes after a drop, so a quiet stretch is not evidence of anything
|
|
45
|
+
* missed; a change is lost only when the listener was down or suspended longer than the
|
|
46
|
+
* change documents live. So the feed emits `resync` in two cases and no other: a failed
|
|
47
|
+
* listener (which delivers nothing more) is started again with a growing wait and its
|
|
48
|
+
* first snapshot is a resync, and a page that becomes visible again after being hidden
|
|
49
|
+
* longer than the change document's life is a resync, since browsers throttle or drop a
|
|
50
|
+
* background page's connection. The live session's own start refetches everything too.
|
|
51
|
+
*/
|
|
52
|
+
declare class FirestoreChangeFeed implements ChangeFeed {
|
|
53
|
+
private readonly resyncAfterHidden;
|
|
54
|
+
private readonly retryDelay;
|
|
55
|
+
private readonly maxRetryDelay;
|
|
56
|
+
private readonly now;
|
|
57
|
+
private readonly visibility;
|
|
58
|
+
private readonly page;
|
|
59
|
+
private readonly changeListeners;
|
|
60
|
+
private readonly resyncListeners;
|
|
61
|
+
private app;
|
|
62
|
+
private db;
|
|
63
|
+
private uid;
|
|
64
|
+
private unsubscribe;
|
|
65
|
+
private retry;
|
|
66
|
+
private failures;
|
|
67
|
+
private lastSeen;
|
|
68
|
+
/**
|
|
69
|
+
* The ids of the documents already delivered (or primed) at the `lastSeen` instant,
|
|
70
|
+
* replaced whenever it advances. One commit writes its row and list documents with one
|
|
71
|
+
* server timestamp, and a listener may deliver them in two snapshots, so a document at
|
|
72
|
+
* the last instant seen is new unless its id is here.
|
|
73
|
+
*/
|
|
74
|
+
private seenAtLastSeen;
|
|
75
|
+
/** When the page went hidden, while it is; undefined while visible. */
|
|
76
|
+
private hiddenSince;
|
|
77
|
+
private readonly onVisibilityChange;
|
|
78
|
+
private readonly onPageHide;
|
|
79
|
+
private readonly onPageShow;
|
|
80
|
+
constructor(options?: FirestoreFeedOptions);
|
|
81
|
+
/** Whether a listener is held: the feed is between start and stop. */
|
|
82
|
+
get started(): boolean;
|
|
83
|
+
start(identity: LiveIdentity): Promise<void>;
|
|
84
|
+
stop(): Promise<void>;
|
|
85
|
+
onChange(listener: (event: ChangeEvent) => void): () => void;
|
|
86
|
+
onResync(listener: () => void): () => void;
|
|
87
|
+
/** The page went hidden: the clock starts, once. */
|
|
88
|
+
private hidden;
|
|
89
|
+
/** The page is visible again: hidden longer than the change documents live, everything is refetched. */
|
|
90
|
+
private visible;
|
|
91
|
+
/** Starts the listener over the change set from the last change seen; `recovering` says an error ended the one before. */
|
|
92
|
+
private listen;
|
|
93
|
+
private scheduleRestart;
|
|
94
|
+
/** The first snapshot of a listener: the latest change in it is the last seen, its documents at that instant are seen, and nothing is emitted. */
|
|
95
|
+
private prime;
|
|
96
|
+
/**
|
|
97
|
+
* A later snapshot: the documents added or moved past the last change seen are events,
|
|
98
|
+
* in `at` order, and so is a document at that very instant not delivered yet.
|
|
99
|
+
*/
|
|
100
|
+
private deliver;
|
|
101
|
+
/** Whether a document at `at` has not been delivered: later than the last seen, or at that instant with an id not yet seen. */
|
|
102
|
+
private unseen;
|
|
103
|
+
/** Marks a document seen: a later instant replaces the last seen and its set of ids; the same instant adds the id. */
|
|
104
|
+
private advance;
|
|
105
|
+
private emitResync;
|
|
106
|
+
}
|
|
107
|
+
/** The Firestore change feed over the firebase JS SDK. Hand it to `client.live.start`. */
|
|
108
|
+
declare function firestoreChangeFeed(options?: FirestoreFeedOptions): ChangeFeed;
|
|
109
|
+
/** `host:port` as the identity carries the emulator, the host bracketed when it is an IPv6 address. */
|
|
110
|
+
declare function hostAndPort(address: string): {
|
|
111
|
+
host: string;
|
|
112
|
+
port: number;
|
|
113
|
+
};
|
|
114
|
+
/** A Firestore timestamp as unix microseconds in decimal: the version a refetch carries. */
|
|
115
|
+
declare function microseconds(at: TimestampLike): string;
|
|
116
|
+
|
|
117
|
+
export { FirestoreChangeFeed, firestoreChangeFeed, hostAndPort, microseconds };
|
|
118
|
+
export type { FirestoreFeedOptions, VisibilityEvents };
|