@koolbase/core 12.11.1 → 12.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/collection-controller.d.ts +3 -2
- package/dist/cjs/react/use-record.d.ts +3 -1
- package/dist/cjs/react/use-record.js +5 -3
- package/dist/cjs/realtime.d.ts +16 -0
- package/dist/cjs/realtime.js +84 -10
- package/dist/cjs/record-controller.d.ts +14 -1
- package/dist/cjs/record-controller.js +49 -1
- package/dist/esm/collection-controller.d.ts +3 -2
- package/dist/esm/react/use-record.d.ts +3 -1
- package/dist/esm/react/use-record.js +5 -3
- package/dist/esm/realtime.d.ts +16 -0
- package/dist/esm/realtime.js +84 -10
- package/dist/esm/record-controller.d.ts +14 -1
- package/dist/esm/record-controller.js +49 -1
- package/package.json +1 -1
|
@@ -11,8 +11,9 @@ export interface CollectionQuery {
|
|
|
11
11
|
/**
|
|
12
12
|
* Re-read page one, silently, when Koolbase realtime reports a record
|
|
13
13
|
* created, updated or deleted in this collection (debounced: a burst is one
|
|
14
|
-
* read).
|
|
15
|
-
* behaves as a normal list
|
|
14
|
+
* read). Signed out, only a collection anyone can read (read rule
|
|
15
|
+
* "public") is live; any other behaves as a normal list until a user signs
|
|
16
|
+
* in, and goes live then.
|
|
16
17
|
*/
|
|
17
18
|
live?: boolean;
|
|
18
19
|
}
|
|
@@ -3,4 +3,6 @@ export interface UseRecordResult extends RecordState {
|
|
|
3
3
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
4
4
|
refresh: () => Promise<void>;
|
|
5
5
|
}
|
|
6
|
-
export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined
|
|
6
|
+
export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined, options?: {
|
|
7
|
+
live?: boolean;
|
|
8
|
+
}) => UseRecordResult;
|
|
@@ -11,13 +11,15 @@ const index_js_1 = require("../index.js");
|
|
|
11
11
|
const noop = () => { };
|
|
12
12
|
const done = () => Promise.resolve();
|
|
13
13
|
function createUseRecord(getDb) {
|
|
14
|
-
return function useRecord(collection, id) {
|
|
15
|
-
|
|
14
|
+
return function useRecord(collection, id, options = {}) {
|
|
15
|
+
// live is part of the key: switching it starts a fresh controller.
|
|
16
|
+
const live = options.live === true;
|
|
17
|
+
const key = JSON.stringify([collection, id ?? '', ...(live ? ['live'] : [])]);
|
|
16
18
|
const [current, setCurrent] = (0, react_1.useState)(null);
|
|
17
19
|
(0, react_1.useEffect)(() => {
|
|
18
20
|
// Created here, not during render: under React strict mode an effect
|
|
19
21
|
// runs, cleans up and runs again, and each run gets its own controller.
|
|
20
|
-
const controller = new index_js_1.KoolbaseRecordController(getDb(), collection, id ?? '');
|
|
22
|
+
const controller = new index_js_1.KoolbaseRecordController(getDb(), collection, id ?? '', { live });
|
|
21
23
|
setCurrent({ key, controller });
|
|
22
24
|
void controller.load();
|
|
23
25
|
return () => controller.dispose();
|
package/dist/cjs/realtime.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class KoolbaseRealtime {
|
|
|
9
9
|
private reconnectAttempts;
|
|
10
10
|
private reconnectTimer;
|
|
11
11
|
private connecting;
|
|
12
|
+
private anonymous;
|
|
13
|
+
private connectedAs;
|
|
14
|
+
private generation;
|
|
12
15
|
private idleTimer;
|
|
13
16
|
/** How long an unused connection stays open, in ms: a screen that comes
|
|
14
17
|
* straight back (navigating back, React's development double start)
|
|
@@ -30,6 +33,9 @@ export declare class KoolbaseRealtime {
|
|
|
30
33
|
private connect;
|
|
31
34
|
private sendSubscribe;
|
|
32
35
|
private sendUnsubscribe;
|
|
36
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
37
|
+
* give, and the server uses the public key's project. */
|
|
38
|
+
private send;
|
|
33
39
|
/**
|
|
34
40
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
35
41
|
*
|
|
@@ -51,6 +57,16 @@ export declare class KoolbaseRealtime {
|
|
|
51
57
|
* it: reconnecting needs a subscriber.
|
|
52
58
|
*/
|
|
53
59
|
private scheduleIdleClose;
|
|
60
|
+
/**
|
|
61
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
62
|
+
* open connection was made for the previous session -- signed out it sees
|
|
63
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
64
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
65
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
66
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
67
|
+
* signed in then.
|
|
68
|
+
*/
|
|
69
|
+
sessionChanged(): void;
|
|
54
70
|
disconnect(): void;
|
|
55
71
|
}
|
|
56
72
|
export {};
|
package/dist/cjs/realtime.js
CHANGED
|
@@ -41,6 +41,15 @@ class KoolbaseRealtime {
|
|
|
41
41
|
this.reconnectAttempts = 0;
|
|
42
42
|
this.reconnectTimer = null;
|
|
43
43
|
this.connecting = false;
|
|
44
|
+
// The open connection is a signed-out visitor's (the public key): its
|
|
45
|
+
// messages leave project_id out.
|
|
46
|
+
this.anonymous = false;
|
|
47
|
+
// Whose session the open connection was made for: the user id, or null
|
|
48
|
+
// when signed out. sessionChanged compares against it.
|
|
49
|
+
this.connectedAs = null;
|
|
50
|
+
// Bumped by sessionChanged: a connect still waiting for its token gives up
|
|
51
|
+
// rather than open a connection for the old session.
|
|
52
|
+
this.generation = 0;
|
|
44
53
|
// Pending close of an unused connection (scheduleIdleClose); null while
|
|
45
54
|
// anyone is subscribed.
|
|
46
55
|
this.idleTimer = null;
|
|
@@ -101,28 +110,49 @@ class KoolbaseRealtime {
|
|
|
101
110
|
// double start -- found nobody connecting and opened its own socket; only
|
|
102
111
|
// the last was remembered, and the others stayed open for good.
|
|
103
112
|
this.connecting = true;
|
|
113
|
+
const generation = this.generation;
|
|
104
114
|
let token;
|
|
105
115
|
try {
|
|
106
116
|
token = await this.getToken();
|
|
107
117
|
}
|
|
108
118
|
catch {
|
|
109
|
-
|
|
119
|
+
// A session that could not be refreshed is still a session: try again
|
|
120
|
+
// later, never as a signed-out visitor.
|
|
121
|
+
if (generation !== this.generation)
|
|
122
|
+
return;
|
|
123
|
+
this.connecting = false;
|
|
124
|
+
this.scheduleReconnect();
|
|
125
|
+
return;
|
|
110
126
|
}
|
|
127
|
+
// The session changed while the token was on its way: sessionChanged
|
|
128
|
+
// has started the connection for the new one.
|
|
129
|
+
if (generation !== this.generation)
|
|
130
|
+
return;
|
|
111
131
|
if (this.listeners.size === 0) {
|
|
112
132
|
// Everyone left while the token was on its way: nothing to connect for.
|
|
113
133
|
this.connecting = false;
|
|
114
134
|
return;
|
|
115
135
|
}
|
|
116
|
-
|
|
136
|
+
const userId = this.getUserId?.() ?? null;
|
|
137
|
+
if (!token && userId) {
|
|
138
|
+
// Signed in, but no usable token right now: try again later rather
|
|
139
|
+
// than drop to public-only.
|
|
117
140
|
this.connecting = false;
|
|
118
|
-
this.scheduleReconnect();
|
|
141
|
+
this.scheduleReconnect();
|
|
119
142
|
return;
|
|
120
143
|
}
|
|
121
|
-
|
|
144
|
+
// Signed out: the project's public key, and only collections anyone can
|
|
145
|
+
// read. The server pins the connection to the key's project.
|
|
146
|
+
this.anonymous = !token;
|
|
147
|
+
this.connectedAs = token ? userId : null;
|
|
148
|
+
this.projectId = token ? projectIdFromToken(token) : null;
|
|
149
|
+
const credential = token
|
|
150
|
+
? `token=${encodeURIComponent(token)}`
|
|
151
|
+
: `public_key=${encodeURIComponent(this.config.publicKey)}`;
|
|
122
152
|
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
123
153
|
let ws;
|
|
124
154
|
try {
|
|
125
|
-
ws = new WebSocket(`${wsUrl}/v1/realtime/ws
|
|
155
|
+
ws = new WebSocket(`${wsUrl}/v1/realtime/ws?${credential}`);
|
|
126
156
|
}
|
|
127
157
|
catch {
|
|
128
158
|
this.connecting = false;
|
|
@@ -186,14 +216,22 @@ class KoolbaseRealtime {
|
|
|
186
216
|
ws.onerror = () => { };
|
|
187
217
|
}
|
|
188
218
|
sendSubscribe(collection) {
|
|
189
|
-
|
|
190
|
-
return;
|
|
191
|
-
this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
|
|
219
|
+
this.send('subscribe', collection);
|
|
192
220
|
}
|
|
193
221
|
sendUnsubscribe(collection) {
|
|
194
|
-
|
|
222
|
+
this.send('unsubscribe', collection);
|
|
223
|
+
}
|
|
224
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
225
|
+
* give, and the server uses the public key's project. */
|
|
226
|
+
send(action, collection) {
|
|
227
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
195
228
|
return;
|
|
196
|
-
|
|
229
|
+
if (this.anonymous) {
|
|
230
|
+
this.ws.send(JSON.stringify({ action, collection }));
|
|
231
|
+
}
|
|
232
|
+
else if (this.projectId) {
|
|
233
|
+
this.ws.send(JSON.stringify({ action, project_id: this.projectId, collection }));
|
|
234
|
+
}
|
|
197
235
|
}
|
|
198
236
|
/**
|
|
199
237
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
@@ -252,6 +290,42 @@ class KoolbaseRealtime {
|
|
|
252
290
|
}, KoolbaseRealtime.idleGraceMs);
|
|
253
291
|
this.idleTimer.unref?.();
|
|
254
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
295
|
+
* open connection was made for the previous session -- signed out it sees
|
|
296
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
297
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
298
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
299
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
300
|
+
* signed in then.
|
|
301
|
+
*/
|
|
302
|
+
sessionChanged() {
|
|
303
|
+
if (!this.ws && !this.connecting && !this.reconnectTimer)
|
|
304
|
+
return;
|
|
305
|
+
if (this.ws && !this.connecting && (this.getUserId?.() ?? null) === this.connectedAs)
|
|
306
|
+
return;
|
|
307
|
+
this.generation += 1;
|
|
308
|
+
if (this.reconnectTimer) {
|
|
309
|
+
clearTimeout(this.reconnectTimer);
|
|
310
|
+
this.reconnectTimer = null;
|
|
311
|
+
}
|
|
312
|
+
const ws = this.ws;
|
|
313
|
+
this.ws = null;
|
|
314
|
+
this.connecting = false;
|
|
315
|
+
this.anonymous = false;
|
|
316
|
+
this.projectId = null;
|
|
317
|
+
this.reconnectAttempts = 0;
|
|
318
|
+
if (ws) {
|
|
319
|
+
// Detached first: this close is ours, not a dropped connection.
|
|
320
|
+
ws.onopen = null;
|
|
321
|
+
ws.onmessage = null;
|
|
322
|
+
ws.onclose = null;
|
|
323
|
+
ws.onerror = null;
|
|
324
|
+
ws.close();
|
|
325
|
+
}
|
|
326
|
+
if (this.listeners.size > 0)
|
|
327
|
+
void this.connect();
|
|
328
|
+
}
|
|
255
329
|
disconnect() {
|
|
256
330
|
if (this.idleTimer) {
|
|
257
331
|
clearTimeout(this.idleTimer);
|
|
@@ -19,7 +19,13 @@ export declare class KoolbaseRecordController {
|
|
|
19
19
|
private readonly listeners;
|
|
20
20
|
private generation;
|
|
21
21
|
private disposed;
|
|
22
|
-
|
|
22
|
+
/** Follow this record through Koolbase realtime (see onLive). */
|
|
23
|
+
private readonly live;
|
|
24
|
+
private unlive;
|
|
25
|
+
private liveTimer;
|
|
26
|
+
constructor(db: RecordReader, collection: string, id: string, options?: {
|
|
27
|
+
live?: boolean;
|
|
28
|
+
});
|
|
23
29
|
getState(): RecordState;
|
|
24
30
|
/** Called on every change. Returns the unsubscribe. */
|
|
25
31
|
subscribe(listener: () => void): () => void;
|
|
@@ -29,6 +35,13 @@ export declare class KoolbaseRecordController {
|
|
|
29
35
|
refresh(): Promise<void>;
|
|
30
36
|
/** Stops all updates. A result still in flight is dropped when it lands. */
|
|
31
37
|
dispose(): void;
|
|
38
|
+
/**
|
|
39
|
+
* A realtime event in this record's collection. Only one naming THIS record
|
|
40
|
+
* counts: an update (or a create, for an id that reappears) re-reads it,
|
|
41
|
+
* silently -- no `refreshing` -- and once per burst (250 ms); a delete is
|
|
42
|
+
* notFound at once, and a read still in flight is dropped.
|
|
43
|
+
*/
|
|
44
|
+
private onLive;
|
|
32
45
|
private fetch;
|
|
33
46
|
private isStale;
|
|
34
47
|
private set;
|
|
@@ -13,11 +13,14 @@
|
|
|
13
13
|
// - refresh() keeps the record shown while it runs, and if it fails.
|
|
14
14
|
// - every fetch has a generation: a result superseded by a later refresh,
|
|
15
15
|
// or landing after dispose(), is dropped.
|
|
16
|
+
// - live (the option): realtime events for THIS record only. An update
|
|
17
|
+
// re-reads it silently, once per burst; a delete is notFound at once.
|
|
16
18
|
// Not yet: an offline fallback from the record cache, which keeps data and
|
|
17
19
|
// revision but not createdAt or createdBy.
|
|
18
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
21
|
exports.KoolbaseRecordController = exports.initialRecordState = void 0;
|
|
20
22
|
const database_errors_js_1 = require("./database-errors.js");
|
|
23
|
+
const live_source_js_1 = require("./live-source.js");
|
|
21
24
|
exports.initialRecordState = Object.freeze({
|
|
22
25
|
status: 'loading',
|
|
23
26
|
record: null,
|
|
@@ -25,14 +28,18 @@ exports.initialRecordState = Object.freeze({
|
|
|
25
28
|
refreshing: false,
|
|
26
29
|
});
|
|
27
30
|
class KoolbaseRecordController {
|
|
28
|
-
constructor(db, collection, id) {
|
|
31
|
+
constructor(db, collection, id, options = {}) {
|
|
29
32
|
this.state = exports.initialRecordState;
|
|
30
33
|
this.listeners = new Set();
|
|
31
34
|
this.generation = 0;
|
|
32
35
|
this.disposed = false;
|
|
36
|
+
// The realtime subscription, and its pending debounced re-read.
|
|
37
|
+
this.unlive = null;
|
|
38
|
+
this.liveTimer = null;
|
|
33
39
|
this.db = db;
|
|
34
40
|
this.collection = collection;
|
|
35
41
|
this.id = id;
|
|
42
|
+
this.live = options.live === true;
|
|
36
43
|
}
|
|
37
44
|
getState() {
|
|
38
45
|
return this.state;
|
|
@@ -50,6 +57,10 @@ class KoolbaseRecordController {
|
|
|
50
57
|
async load() {
|
|
51
58
|
if (this.disposed)
|
|
52
59
|
return;
|
|
60
|
+
// Live needs an id: with none there is nothing to follow.
|
|
61
|
+
if (this.live && this.id) {
|
|
62
|
+
this.unlive ?? (this.unlive = (0, live_source_js_1.liveSource)()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
|
|
63
|
+
}
|
|
53
64
|
await this.fetch();
|
|
54
65
|
}
|
|
55
66
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
@@ -64,8 +75,45 @@ class KoolbaseRecordController {
|
|
|
64
75
|
/** Stops all updates. A result still in flight is dropped when it lands. */
|
|
65
76
|
dispose() {
|
|
66
77
|
this.disposed = true;
|
|
78
|
+
this.unlive?.();
|
|
79
|
+
this.unlive = null;
|
|
80
|
+
if (this.liveTimer) {
|
|
81
|
+
clearTimeout(this.liveTimer);
|
|
82
|
+
this.liveTimer = null;
|
|
83
|
+
}
|
|
67
84
|
this.listeners.clear();
|
|
68
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* A realtime event in this record's collection. Only one naming THIS record
|
|
88
|
+
* counts: an update (or a create, for an id that reappears) re-reads it,
|
|
89
|
+
* silently -- no `refreshing` -- and once per burst (250 ms); a delete is
|
|
90
|
+
* notFound at once, and a read still in flight is dropped.
|
|
91
|
+
*/
|
|
92
|
+
onLive(event) {
|
|
93
|
+
if (this.disposed)
|
|
94
|
+
return;
|
|
95
|
+
const e = (event ?? {});
|
|
96
|
+
const target = e.type === 'deleted' ? e.recordId : e.record?.id;
|
|
97
|
+
if (target !== this.id)
|
|
98
|
+
return;
|
|
99
|
+
if (e.type === 'deleted') {
|
|
100
|
+
if (this.liveTimer) {
|
|
101
|
+
clearTimeout(this.liveTimer);
|
|
102
|
+
this.liveTimer = null;
|
|
103
|
+
}
|
|
104
|
+
this.generation += 1;
|
|
105
|
+
this.set({ status: 'notFound', record: null, error: null, refreshing: false });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.liveTimer)
|
|
109
|
+
return;
|
|
110
|
+
this.liveTimer = setTimeout(() => {
|
|
111
|
+
this.liveTimer = null;
|
|
112
|
+
void this.fetch();
|
|
113
|
+
}, 250);
|
|
114
|
+
// A pending re-read must not be what keeps a Node process alive.
|
|
115
|
+
this.liveTimer.unref?.();
|
|
116
|
+
}
|
|
69
117
|
async fetch() {
|
|
70
118
|
const gen = ++this.generation;
|
|
71
119
|
if (!this.id) {
|
|
@@ -11,8 +11,9 @@ export interface CollectionQuery {
|
|
|
11
11
|
/**
|
|
12
12
|
* Re-read page one, silently, when Koolbase realtime reports a record
|
|
13
13
|
* created, updated or deleted in this collection (debounced: a burst is one
|
|
14
|
-
* read).
|
|
15
|
-
* behaves as a normal list
|
|
14
|
+
* read). Signed out, only a collection anyone can read (read rule
|
|
15
|
+
* "public") is live; any other behaves as a normal list until a user signs
|
|
16
|
+
* in, and goes live then.
|
|
16
17
|
*/
|
|
17
18
|
live?: boolean;
|
|
18
19
|
}
|
|
@@ -3,4 +3,6 @@ export interface UseRecordResult extends RecordState {
|
|
|
3
3
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
4
4
|
refresh: () => Promise<void>;
|
|
5
5
|
}
|
|
6
|
-
export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined
|
|
6
|
+
export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined, options?: {
|
|
7
|
+
live?: boolean;
|
|
8
|
+
}) => UseRecordResult;
|
|
@@ -8,13 +8,15 @@ import { KoolbaseRecordController, initialRecordState, } from '../index.js';
|
|
|
8
8
|
const noop = () => { };
|
|
9
9
|
const done = () => Promise.resolve();
|
|
10
10
|
export function createUseRecord(getDb) {
|
|
11
|
-
return function useRecord(collection, id) {
|
|
12
|
-
|
|
11
|
+
return function useRecord(collection, id, options = {}) {
|
|
12
|
+
// live is part of the key: switching it starts a fresh controller.
|
|
13
|
+
const live = options.live === true;
|
|
14
|
+
const key = JSON.stringify([collection, id ?? '', ...(live ? ['live'] : [])]);
|
|
13
15
|
const [current, setCurrent] = useState(null);
|
|
14
16
|
useEffect(() => {
|
|
15
17
|
// Created here, not during render: under React strict mode an effect
|
|
16
18
|
// runs, cleans up and runs again, and each run gets its own controller.
|
|
17
|
-
const controller = new KoolbaseRecordController(getDb(), collection, id ?? '');
|
|
19
|
+
const controller = new KoolbaseRecordController(getDb(), collection, id ?? '', { live });
|
|
18
20
|
setCurrent({ key, controller });
|
|
19
21
|
void controller.load();
|
|
20
22
|
return () => controller.dispose();
|
package/dist/esm/realtime.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class KoolbaseRealtime {
|
|
|
9
9
|
private reconnectAttempts;
|
|
10
10
|
private reconnectTimer;
|
|
11
11
|
private connecting;
|
|
12
|
+
private anonymous;
|
|
13
|
+
private connectedAs;
|
|
14
|
+
private generation;
|
|
12
15
|
private idleTimer;
|
|
13
16
|
/** How long an unused connection stays open, in ms: a screen that comes
|
|
14
17
|
* straight back (navigating back, React's development double start)
|
|
@@ -30,6 +33,9 @@ export declare class KoolbaseRealtime {
|
|
|
30
33
|
private connect;
|
|
31
34
|
private sendSubscribe;
|
|
32
35
|
private sendUnsubscribe;
|
|
36
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
37
|
+
* give, and the server uses the public key's project. */
|
|
38
|
+
private send;
|
|
33
39
|
/**
|
|
34
40
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
35
41
|
*
|
|
@@ -51,6 +57,16 @@ export declare class KoolbaseRealtime {
|
|
|
51
57
|
* it: reconnecting needs a subscriber.
|
|
52
58
|
*/
|
|
53
59
|
private scheduleIdleClose;
|
|
60
|
+
/**
|
|
61
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
62
|
+
* open connection was made for the previous session -- signed out it sees
|
|
63
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
64
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
65
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
66
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
67
|
+
* signed in then.
|
|
68
|
+
*/
|
|
69
|
+
sessionChanged(): void;
|
|
54
70
|
disconnect(): void;
|
|
55
71
|
}
|
|
56
72
|
export {};
|
package/dist/esm/realtime.js
CHANGED
|
@@ -38,6 +38,15 @@ export class KoolbaseRealtime {
|
|
|
38
38
|
this.reconnectAttempts = 0;
|
|
39
39
|
this.reconnectTimer = null;
|
|
40
40
|
this.connecting = false;
|
|
41
|
+
// The open connection is a signed-out visitor's (the public key): its
|
|
42
|
+
// messages leave project_id out.
|
|
43
|
+
this.anonymous = false;
|
|
44
|
+
// Whose session the open connection was made for: the user id, or null
|
|
45
|
+
// when signed out. sessionChanged compares against it.
|
|
46
|
+
this.connectedAs = null;
|
|
47
|
+
// Bumped by sessionChanged: a connect still waiting for its token gives up
|
|
48
|
+
// rather than open a connection for the old session.
|
|
49
|
+
this.generation = 0;
|
|
41
50
|
// Pending close of an unused connection (scheduleIdleClose); null while
|
|
42
51
|
// anyone is subscribed.
|
|
43
52
|
this.idleTimer = null;
|
|
@@ -98,28 +107,49 @@ export class KoolbaseRealtime {
|
|
|
98
107
|
// double start -- found nobody connecting and opened its own socket; only
|
|
99
108
|
// the last was remembered, and the others stayed open for good.
|
|
100
109
|
this.connecting = true;
|
|
110
|
+
const generation = this.generation;
|
|
101
111
|
let token;
|
|
102
112
|
try {
|
|
103
113
|
token = await this.getToken();
|
|
104
114
|
}
|
|
105
115
|
catch {
|
|
106
|
-
|
|
116
|
+
// A session that could not be refreshed is still a session: try again
|
|
117
|
+
// later, never as a signed-out visitor.
|
|
118
|
+
if (generation !== this.generation)
|
|
119
|
+
return;
|
|
120
|
+
this.connecting = false;
|
|
121
|
+
this.scheduleReconnect();
|
|
122
|
+
return;
|
|
107
123
|
}
|
|
124
|
+
// The session changed while the token was on its way: sessionChanged
|
|
125
|
+
// has started the connection for the new one.
|
|
126
|
+
if (generation !== this.generation)
|
|
127
|
+
return;
|
|
108
128
|
if (this.listeners.size === 0) {
|
|
109
129
|
// Everyone left while the token was on its way: nothing to connect for.
|
|
110
130
|
this.connecting = false;
|
|
111
131
|
return;
|
|
112
132
|
}
|
|
113
|
-
|
|
133
|
+
const userId = this.getUserId?.() ?? null;
|
|
134
|
+
if (!token && userId) {
|
|
135
|
+
// Signed in, but no usable token right now: try again later rather
|
|
136
|
+
// than drop to public-only.
|
|
114
137
|
this.connecting = false;
|
|
115
|
-
this.scheduleReconnect();
|
|
138
|
+
this.scheduleReconnect();
|
|
116
139
|
return;
|
|
117
140
|
}
|
|
118
|
-
|
|
141
|
+
// Signed out: the project's public key, and only collections anyone can
|
|
142
|
+
// read. The server pins the connection to the key's project.
|
|
143
|
+
this.anonymous = !token;
|
|
144
|
+
this.connectedAs = token ? userId : null;
|
|
145
|
+
this.projectId = token ? projectIdFromToken(token) : null;
|
|
146
|
+
const credential = token
|
|
147
|
+
? `token=${encodeURIComponent(token)}`
|
|
148
|
+
: `public_key=${encodeURIComponent(this.config.publicKey)}`;
|
|
119
149
|
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
120
150
|
let ws;
|
|
121
151
|
try {
|
|
122
|
-
ws = new WebSocket(`${wsUrl}/v1/realtime/ws
|
|
152
|
+
ws = new WebSocket(`${wsUrl}/v1/realtime/ws?${credential}`);
|
|
123
153
|
}
|
|
124
154
|
catch {
|
|
125
155
|
this.connecting = false;
|
|
@@ -183,14 +213,22 @@ export class KoolbaseRealtime {
|
|
|
183
213
|
ws.onerror = () => { };
|
|
184
214
|
}
|
|
185
215
|
sendSubscribe(collection) {
|
|
186
|
-
|
|
187
|
-
return;
|
|
188
|
-
this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
|
|
216
|
+
this.send('subscribe', collection);
|
|
189
217
|
}
|
|
190
218
|
sendUnsubscribe(collection) {
|
|
191
|
-
|
|
219
|
+
this.send('unsubscribe', collection);
|
|
220
|
+
}
|
|
221
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
222
|
+
* give, and the server uses the public key's project. */
|
|
223
|
+
send(action, collection) {
|
|
224
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
192
225
|
return;
|
|
193
|
-
|
|
226
|
+
if (this.anonymous) {
|
|
227
|
+
this.ws.send(JSON.stringify({ action, collection }));
|
|
228
|
+
}
|
|
229
|
+
else if (this.projectId) {
|
|
230
|
+
this.ws.send(JSON.stringify({ action, project_id: this.projectId, collection }));
|
|
231
|
+
}
|
|
194
232
|
}
|
|
195
233
|
/**
|
|
196
234
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
@@ -249,6 +287,42 @@ export class KoolbaseRealtime {
|
|
|
249
287
|
}, KoolbaseRealtime.idleGraceMs);
|
|
250
288
|
this.idleTimer.unref?.();
|
|
251
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
292
|
+
* open connection was made for the previous session -- signed out it sees
|
|
293
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
294
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
295
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
296
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
297
|
+
* signed in then.
|
|
298
|
+
*/
|
|
299
|
+
sessionChanged() {
|
|
300
|
+
if (!this.ws && !this.connecting && !this.reconnectTimer)
|
|
301
|
+
return;
|
|
302
|
+
if (this.ws && !this.connecting && (this.getUserId?.() ?? null) === this.connectedAs)
|
|
303
|
+
return;
|
|
304
|
+
this.generation += 1;
|
|
305
|
+
if (this.reconnectTimer) {
|
|
306
|
+
clearTimeout(this.reconnectTimer);
|
|
307
|
+
this.reconnectTimer = null;
|
|
308
|
+
}
|
|
309
|
+
const ws = this.ws;
|
|
310
|
+
this.ws = null;
|
|
311
|
+
this.connecting = false;
|
|
312
|
+
this.anonymous = false;
|
|
313
|
+
this.projectId = null;
|
|
314
|
+
this.reconnectAttempts = 0;
|
|
315
|
+
if (ws) {
|
|
316
|
+
// Detached first: this close is ours, not a dropped connection.
|
|
317
|
+
ws.onopen = null;
|
|
318
|
+
ws.onmessage = null;
|
|
319
|
+
ws.onclose = null;
|
|
320
|
+
ws.onerror = null;
|
|
321
|
+
ws.close();
|
|
322
|
+
}
|
|
323
|
+
if (this.listeners.size > 0)
|
|
324
|
+
void this.connect();
|
|
325
|
+
}
|
|
252
326
|
disconnect() {
|
|
253
327
|
if (this.idleTimer) {
|
|
254
328
|
clearTimeout(this.idleTimer);
|
|
@@ -19,7 +19,13 @@ export declare class KoolbaseRecordController {
|
|
|
19
19
|
private readonly listeners;
|
|
20
20
|
private generation;
|
|
21
21
|
private disposed;
|
|
22
|
-
|
|
22
|
+
/** Follow this record through Koolbase realtime (see onLive). */
|
|
23
|
+
private readonly live;
|
|
24
|
+
private unlive;
|
|
25
|
+
private liveTimer;
|
|
26
|
+
constructor(db: RecordReader, collection: string, id: string, options?: {
|
|
27
|
+
live?: boolean;
|
|
28
|
+
});
|
|
23
29
|
getState(): RecordState;
|
|
24
30
|
/** Called on every change. Returns the unsubscribe. */
|
|
25
31
|
subscribe(listener: () => void): () => void;
|
|
@@ -29,6 +35,13 @@ export declare class KoolbaseRecordController {
|
|
|
29
35
|
refresh(): Promise<void>;
|
|
30
36
|
/** Stops all updates. A result still in flight is dropped when it lands. */
|
|
31
37
|
dispose(): void;
|
|
38
|
+
/**
|
|
39
|
+
* A realtime event in this record's collection. Only one naming THIS record
|
|
40
|
+
* counts: an update (or a create, for an id that reappears) re-reads it,
|
|
41
|
+
* silently -- no `refreshing` -- and once per burst (250 ms); a delete is
|
|
42
|
+
* notFound at once, and a read still in flight is dropped.
|
|
43
|
+
*/
|
|
44
|
+
private onLive;
|
|
32
45
|
private fetch;
|
|
33
46
|
private isStale;
|
|
34
47
|
private set;
|
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
// - refresh() keeps the record shown while it runs, and if it fails.
|
|
13
13
|
// - every fetch has a generation: a result superseded by a later refresh,
|
|
14
14
|
// or landing after dispose(), is dropped.
|
|
15
|
+
// - live (the option): realtime events for THIS record only. An update
|
|
16
|
+
// re-reads it silently, once per burst; a delete is notFound at once.
|
|
15
17
|
// Not yet: an offline fallback from the record cache, which keeps data and
|
|
16
18
|
// revision but not createdAt or createdBy.
|
|
17
19
|
import { KoolbaseNotFoundError } from './database-errors.js';
|
|
20
|
+
import { liveSource } from './live-source.js';
|
|
18
21
|
export const initialRecordState = Object.freeze({
|
|
19
22
|
status: 'loading',
|
|
20
23
|
record: null,
|
|
@@ -22,14 +25,18 @@ export const initialRecordState = Object.freeze({
|
|
|
22
25
|
refreshing: false,
|
|
23
26
|
});
|
|
24
27
|
export class KoolbaseRecordController {
|
|
25
|
-
constructor(db, collection, id) {
|
|
28
|
+
constructor(db, collection, id, options = {}) {
|
|
26
29
|
this.state = initialRecordState;
|
|
27
30
|
this.listeners = new Set();
|
|
28
31
|
this.generation = 0;
|
|
29
32
|
this.disposed = false;
|
|
33
|
+
// The realtime subscription, and its pending debounced re-read.
|
|
34
|
+
this.unlive = null;
|
|
35
|
+
this.liveTimer = null;
|
|
30
36
|
this.db = db;
|
|
31
37
|
this.collection = collection;
|
|
32
38
|
this.id = id;
|
|
39
|
+
this.live = options.live === true;
|
|
33
40
|
}
|
|
34
41
|
getState() {
|
|
35
42
|
return this.state;
|
|
@@ -47,6 +54,10 @@ export class KoolbaseRecordController {
|
|
|
47
54
|
async load() {
|
|
48
55
|
if (this.disposed)
|
|
49
56
|
return;
|
|
57
|
+
// Live needs an id: with none there is nothing to follow.
|
|
58
|
+
if (this.live && this.id) {
|
|
59
|
+
this.unlive ?? (this.unlive = liveSource()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
|
|
60
|
+
}
|
|
50
61
|
await this.fetch();
|
|
51
62
|
}
|
|
52
63
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
@@ -61,8 +72,45 @@ export class KoolbaseRecordController {
|
|
|
61
72
|
/** Stops all updates. A result still in flight is dropped when it lands. */
|
|
62
73
|
dispose() {
|
|
63
74
|
this.disposed = true;
|
|
75
|
+
this.unlive?.();
|
|
76
|
+
this.unlive = null;
|
|
77
|
+
if (this.liveTimer) {
|
|
78
|
+
clearTimeout(this.liveTimer);
|
|
79
|
+
this.liveTimer = null;
|
|
80
|
+
}
|
|
64
81
|
this.listeners.clear();
|
|
65
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* A realtime event in this record's collection. Only one naming THIS record
|
|
85
|
+
* counts: an update (or a create, for an id that reappears) re-reads it,
|
|
86
|
+
* silently -- no `refreshing` -- and once per burst (250 ms); a delete is
|
|
87
|
+
* notFound at once, and a read still in flight is dropped.
|
|
88
|
+
*/
|
|
89
|
+
onLive(event) {
|
|
90
|
+
if (this.disposed)
|
|
91
|
+
return;
|
|
92
|
+
const e = (event ?? {});
|
|
93
|
+
const target = e.type === 'deleted' ? e.recordId : e.record?.id;
|
|
94
|
+
if (target !== this.id)
|
|
95
|
+
return;
|
|
96
|
+
if (e.type === 'deleted') {
|
|
97
|
+
if (this.liveTimer) {
|
|
98
|
+
clearTimeout(this.liveTimer);
|
|
99
|
+
this.liveTimer = null;
|
|
100
|
+
}
|
|
101
|
+
this.generation += 1;
|
|
102
|
+
this.set({ status: 'notFound', record: null, error: null, refreshing: false });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (this.liveTimer)
|
|
106
|
+
return;
|
|
107
|
+
this.liveTimer = setTimeout(() => {
|
|
108
|
+
this.liveTimer = null;
|
|
109
|
+
void this.fetch();
|
|
110
|
+
}, 250);
|
|
111
|
+
// A pending re-read must not be what keeps a Node process alive.
|
|
112
|
+
this.liveTimer.unref?.();
|
|
113
|
+
}
|
|
66
114
|
async fetch() {
|
|
67
115
|
const gen = ++this.generation;
|
|
68
116
|
if (!this.id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koolbase/core",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.13.0",
|
|
4
4
|
"description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"types": "./dist/esm/index.d.ts",
|