@koolbase/core 12.11.0 → 12.12.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/react/use-record.d.ts +3 -1
- package/dist/cjs/react/use-record.js +5 -3
- package/dist/cjs/realtime.d.ts +12 -0
- package/dist/cjs/realtime.js +81 -7
- package/dist/cjs/record-controller.d.ts +14 -1
- package/dist/cjs/record-controller.js +49 -1
- 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 +12 -0
- package/dist/esm/realtime.js +81 -7
- package/dist/esm/record-controller.d.ts +14 -1
- package/dist/esm/record-controller.js +49 -1
- package/package.json +1 -1
|
@@ -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,11 @@ export declare class KoolbaseRealtime {
|
|
|
9
9
|
private reconnectAttempts;
|
|
10
10
|
private reconnectTimer;
|
|
11
11
|
private connecting;
|
|
12
|
+
private idleTimer;
|
|
13
|
+
/** How long an unused connection stays open, in ms: a screen that comes
|
|
14
|
+
* straight back (navigating back, React's development double start)
|
|
15
|
+
* reuses it instead of opening another. */
|
|
16
|
+
static idleGraceMs: number;
|
|
12
17
|
/**
|
|
13
18
|
* Identifies whose cache a seen record belongs in.
|
|
14
19
|
*
|
|
@@ -39,6 +44,13 @@ export declare class KoolbaseRealtime {
|
|
|
39
44
|
* does not accumulate delay.
|
|
40
45
|
*/
|
|
41
46
|
private scheduleReconnect;
|
|
47
|
+
/**
|
|
48
|
+
* Closes the connection once nobody is subscribed -- after a moment, not at
|
|
49
|
+
* once: a screen that unmounts and mounts again keeps the connection it had.
|
|
50
|
+
* A subscribe within the moment cancels the close. Nothing reconnects after
|
|
51
|
+
* it: reconnecting needs a subscriber.
|
|
52
|
+
*/
|
|
53
|
+
private scheduleIdleClose;
|
|
42
54
|
disconnect(): void;
|
|
43
55
|
}
|
|
44
56
|
export {};
|
package/dist/cjs/realtime.js
CHANGED
|
@@ -41,6 +41,9 @@ class KoolbaseRealtime {
|
|
|
41
41
|
this.reconnectAttempts = 0;
|
|
42
42
|
this.reconnectTimer = null;
|
|
43
43
|
this.connecting = false;
|
|
44
|
+
// Pending close of an unused connection (scheduleIdleClose); null while
|
|
45
|
+
// anyone is subscribed.
|
|
46
|
+
this.idleTimer = null;
|
|
44
47
|
this.config = config;
|
|
45
48
|
this.getToken = getToken;
|
|
46
49
|
this.getUserId = getUserId;
|
|
@@ -61,6 +64,10 @@ class KoolbaseRealtime {
|
|
|
61
64
|
await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
|
|
62
65
|
}
|
|
63
66
|
subscribe(collection, callback) {
|
|
67
|
+
if (this.idleTimer) {
|
|
68
|
+
clearTimeout(this.idleTimer);
|
|
69
|
+
this.idleTimer = null;
|
|
70
|
+
}
|
|
64
71
|
if (!this.listeners.has(collection))
|
|
65
72
|
this.listeners.set(collection, []);
|
|
66
73
|
this.listeners.get(collection).push(callback);
|
|
@@ -71,13 +78,16 @@ class KoolbaseRealtime {
|
|
|
71
78
|
void this.connect();
|
|
72
79
|
}
|
|
73
80
|
return () => {
|
|
74
|
-
const callbacks = this.listeners.get(collection)
|
|
75
|
-
const i = callbacks.indexOf(callback);
|
|
76
|
-
if (i
|
|
77
|
-
|
|
81
|
+
const callbacks = this.listeners.get(collection);
|
|
82
|
+
const i = callbacks ? callbacks.indexOf(callback) : -1;
|
|
83
|
+
if (!callbacks || i === -1)
|
|
84
|
+
return; // already unsubscribed: a second call does nothing
|
|
85
|
+
callbacks.splice(i, 1);
|
|
78
86
|
if (callbacks.length === 0) {
|
|
79
87
|
this.listeners.delete(collection);
|
|
80
88
|
this.sendUnsubscribe(collection);
|
|
89
|
+
if (this.listeners.size === 0)
|
|
90
|
+
this.scheduleIdleClose();
|
|
81
91
|
}
|
|
82
92
|
};
|
|
83
93
|
}
|
|
@@ -86,15 +96,39 @@ class KoolbaseRealtime {
|
|
|
86
96
|
return;
|
|
87
97
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
|
|
88
98
|
return;
|
|
89
|
-
|
|
99
|
+
// Claimed BEFORE the token is awaited. Claimed after it, every subscribe
|
|
100
|
+
// arriving meanwhile -- two live lists on one screen, React's development
|
|
101
|
+
// double start -- found nobody connecting and opened its own socket; only
|
|
102
|
+
// the last was remembered, and the others stayed open for good.
|
|
103
|
+
this.connecting = true;
|
|
104
|
+
let token;
|
|
105
|
+
try {
|
|
106
|
+
token = await this.getToken();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
token = null;
|
|
110
|
+
}
|
|
111
|
+
if (this.listeners.size === 0) {
|
|
112
|
+
// Everyone left while the token was on its way: nothing to connect for.
|
|
113
|
+
this.connecting = false;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
90
116
|
if (!token) {
|
|
117
|
+
this.connecting = false;
|
|
91
118
|
this.scheduleReconnect(); // sign-in may be in flight
|
|
92
119
|
return;
|
|
93
120
|
}
|
|
94
121
|
this.projectId = projectIdFromToken(token);
|
|
95
|
-
this.connecting = true;
|
|
96
122
|
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
97
|
-
|
|
123
|
+
let ws;
|
|
124
|
+
try {
|
|
125
|
+
ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
this.connecting = false;
|
|
129
|
+
this.scheduleReconnect();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
98
132
|
this.ws = ws;
|
|
99
133
|
ws.onopen = () => {
|
|
100
134
|
this.connecting = false;
|
|
@@ -187,11 +221,47 @@ class KoolbaseRealtime {
|
|
|
187
221
|
}, delay);
|
|
188
222
|
this.reconnectTimer.unref?.();
|
|
189
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Closes the connection once nobody is subscribed -- after a moment, not at
|
|
226
|
+
* once: a screen that unmounts and mounts again keeps the connection it had.
|
|
227
|
+
* A subscribe within the moment cancels the close. Nothing reconnects after
|
|
228
|
+
* it: reconnecting needs a subscriber.
|
|
229
|
+
*/
|
|
230
|
+
scheduleIdleClose() {
|
|
231
|
+
if (this.idleTimer)
|
|
232
|
+
clearTimeout(this.idleTimer);
|
|
233
|
+
this.idleTimer = setTimeout(() => {
|
|
234
|
+
this.idleTimer = null;
|
|
235
|
+
if (this.listeners.size > 0)
|
|
236
|
+
return;
|
|
237
|
+
if (this.reconnectTimer) {
|
|
238
|
+
clearTimeout(this.reconnectTimer);
|
|
239
|
+
this.reconnectTimer = null;
|
|
240
|
+
}
|
|
241
|
+
const ws = this.ws;
|
|
242
|
+
this.ws = null;
|
|
243
|
+
this.connecting = false;
|
|
244
|
+
if (ws) {
|
|
245
|
+
// Detached first: this close is ours, not a dropped connection.
|
|
246
|
+
ws.onopen = null;
|
|
247
|
+
ws.onmessage = null;
|
|
248
|
+
ws.onclose = null;
|
|
249
|
+
ws.onerror = null;
|
|
250
|
+
ws.close();
|
|
251
|
+
}
|
|
252
|
+
}, KoolbaseRealtime.idleGraceMs);
|
|
253
|
+
this.idleTimer.unref?.();
|
|
254
|
+
}
|
|
190
255
|
disconnect() {
|
|
256
|
+
if (this.idleTimer) {
|
|
257
|
+
clearTimeout(this.idleTimer);
|
|
258
|
+
this.idleTimer = null;
|
|
259
|
+
}
|
|
191
260
|
if (this.reconnectTimer) {
|
|
192
261
|
clearTimeout(this.reconnectTimer);
|
|
193
262
|
this.reconnectTimer = null;
|
|
194
263
|
}
|
|
264
|
+
this.connecting = false;
|
|
195
265
|
this.ws?.close();
|
|
196
266
|
this.ws = null;
|
|
197
267
|
this.projectId = null;
|
|
@@ -199,3 +269,7 @@ class KoolbaseRealtime {
|
|
|
199
269
|
}
|
|
200
270
|
}
|
|
201
271
|
exports.KoolbaseRealtime = KoolbaseRealtime;
|
|
272
|
+
/** How long an unused connection stays open, in ms: a screen that comes
|
|
273
|
+
* straight back (navigating back, React's development double start)
|
|
274
|
+
* reuses it instead of opening another. */
|
|
275
|
+
KoolbaseRealtime.idleGraceMs = 1000;
|
|
@@ -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) {
|
|
@@ -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,11 @@ export declare class KoolbaseRealtime {
|
|
|
9
9
|
private reconnectAttempts;
|
|
10
10
|
private reconnectTimer;
|
|
11
11
|
private connecting;
|
|
12
|
+
private idleTimer;
|
|
13
|
+
/** How long an unused connection stays open, in ms: a screen that comes
|
|
14
|
+
* straight back (navigating back, React's development double start)
|
|
15
|
+
* reuses it instead of opening another. */
|
|
16
|
+
static idleGraceMs: number;
|
|
12
17
|
/**
|
|
13
18
|
* Identifies whose cache a seen record belongs in.
|
|
14
19
|
*
|
|
@@ -39,6 +44,13 @@ export declare class KoolbaseRealtime {
|
|
|
39
44
|
* does not accumulate delay.
|
|
40
45
|
*/
|
|
41
46
|
private scheduleReconnect;
|
|
47
|
+
/**
|
|
48
|
+
* Closes the connection once nobody is subscribed -- after a moment, not at
|
|
49
|
+
* once: a screen that unmounts and mounts again keeps the connection it had.
|
|
50
|
+
* A subscribe within the moment cancels the close. Nothing reconnects after
|
|
51
|
+
* it: reconnecting needs a subscriber.
|
|
52
|
+
*/
|
|
53
|
+
private scheduleIdleClose;
|
|
42
54
|
disconnect(): void;
|
|
43
55
|
}
|
|
44
56
|
export {};
|
package/dist/esm/realtime.js
CHANGED
|
@@ -38,6 +38,9 @@ export class KoolbaseRealtime {
|
|
|
38
38
|
this.reconnectAttempts = 0;
|
|
39
39
|
this.reconnectTimer = null;
|
|
40
40
|
this.connecting = false;
|
|
41
|
+
// Pending close of an unused connection (scheduleIdleClose); null while
|
|
42
|
+
// anyone is subscribed.
|
|
43
|
+
this.idleTimer = null;
|
|
41
44
|
this.config = config;
|
|
42
45
|
this.getToken = getToken;
|
|
43
46
|
this.getUserId = getUserId;
|
|
@@ -58,6 +61,10 @@ export class KoolbaseRealtime {
|
|
|
58
61
|
await removeCachedRecord(userId, recordId);
|
|
59
62
|
}
|
|
60
63
|
subscribe(collection, callback) {
|
|
64
|
+
if (this.idleTimer) {
|
|
65
|
+
clearTimeout(this.idleTimer);
|
|
66
|
+
this.idleTimer = null;
|
|
67
|
+
}
|
|
61
68
|
if (!this.listeners.has(collection))
|
|
62
69
|
this.listeners.set(collection, []);
|
|
63
70
|
this.listeners.get(collection).push(callback);
|
|
@@ -68,13 +75,16 @@ export class KoolbaseRealtime {
|
|
|
68
75
|
void this.connect();
|
|
69
76
|
}
|
|
70
77
|
return () => {
|
|
71
|
-
const callbacks = this.listeners.get(collection)
|
|
72
|
-
const i = callbacks.indexOf(callback);
|
|
73
|
-
if (i
|
|
74
|
-
|
|
78
|
+
const callbacks = this.listeners.get(collection);
|
|
79
|
+
const i = callbacks ? callbacks.indexOf(callback) : -1;
|
|
80
|
+
if (!callbacks || i === -1)
|
|
81
|
+
return; // already unsubscribed: a second call does nothing
|
|
82
|
+
callbacks.splice(i, 1);
|
|
75
83
|
if (callbacks.length === 0) {
|
|
76
84
|
this.listeners.delete(collection);
|
|
77
85
|
this.sendUnsubscribe(collection);
|
|
86
|
+
if (this.listeners.size === 0)
|
|
87
|
+
this.scheduleIdleClose();
|
|
78
88
|
}
|
|
79
89
|
};
|
|
80
90
|
}
|
|
@@ -83,15 +93,39 @@ export class KoolbaseRealtime {
|
|
|
83
93
|
return;
|
|
84
94
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
|
|
85
95
|
return;
|
|
86
|
-
|
|
96
|
+
// Claimed BEFORE the token is awaited. Claimed after it, every subscribe
|
|
97
|
+
// arriving meanwhile -- two live lists on one screen, React's development
|
|
98
|
+
// double start -- found nobody connecting and opened its own socket; only
|
|
99
|
+
// the last was remembered, and the others stayed open for good.
|
|
100
|
+
this.connecting = true;
|
|
101
|
+
let token;
|
|
102
|
+
try {
|
|
103
|
+
token = await this.getToken();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
token = null;
|
|
107
|
+
}
|
|
108
|
+
if (this.listeners.size === 0) {
|
|
109
|
+
// Everyone left while the token was on its way: nothing to connect for.
|
|
110
|
+
this.connecting = false;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
87
113
|
if (!token) {
|
|
114
|
+
this.connecting = false;
|
|
88
115
|
this.scheduleReconnect(); // sign-in may be in flight
|
|
89
116
|
return;
|
|
90
117
|
}
|
|
91
118
|
this.projectId = projectIdFromToken(token);
|
|
92
|
-
this.connecting = true;
|
|
93
119
|
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
94
|
-
|
|
120
|
+
let ws;
|
|
121
|
+
try {
|
|
122
|
+
ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
this.connecting = false;
|
|
126
|
+
this.scheduleReconnect();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
95
129
|
this.ws = ws;
|
|
96
130
|
ws.onopen = () => {
|
|
97
131
|
this.connecting = false;
|
|
@@ -184,14 +218,54 @@ export class KoolbaseRealtime {
|
|
|
184
218
|
}, delay);
|
|
185
219
|
this.reconnectTimer.unref?.();
|
|
186
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Closes the connection once nobody is subscribed -- after a moment, not at
|
|
223
|
+
* once: a screen that unmounts and mounts again keeps the connection it had.
|
|
224
|
+
* A subscribe within the moment cancels the close. Nothing reconnects after
|
|
225
|
+
* it: reconnecting needs a subscriber.
|
|
226
|
+
*/
|
|
227
|
+
scheduleIdleClose() {
|
|
228
|
+
if (this.idleTimer)
|
|
229
|
+
clearTimeout(this.idleTimer);
|
|
230
|
+
this.idleTimer = setTimeout(() => {
|
|
231
|
+
this.idleTimer = null;
|
|
232
|
+
if (this.listeners.size > 0)
|
|
233
|
+
return;
|
|
234
|
+
if (this.reconnectTimer) {
|
|
235
|
+
clearTimeout(this.reconnectTimer);
|
|
236
|
+
this.reconnectTimer = null;
|
|
237
|
+
}
|
|
238
|
+
const ws = this.ws;
|
|
239
|
+
this.ws = null;
|
|
240
|
+
this.connecting = false;
|
|
241
|
+
if (ws) {
|
|
242
|
+
// Detached first: this close is ours, not a dropped connection.
|
|
243
|
+
ws.onopen = null;
|
|
244
|
+
ws.onmessage = null;
|
|
245
|
+
ws.onclose = null;
|
|
246
|
+
ws.onerror = null;
|
|
247
|
+
ws.close();
|
|
248
|
+
}
|
|
249
|
+
}, KoolbaseRealtime.idleGraceMs);
|
|
250
|
+
this.idleTimer.unref?.();
|
|
251
|
+
}
|
|
187
252
|
disconnect() {
|
|
253
|
+
if (this.idleTimer) {
|
|
254
|
+
clearTimeout(this.idleTimer);
|
|
255
|
+
this.idleTimer = null;
|
|
256
|
+
}
|
|
188
257
|
if (this.reconnectTimer) {
|
|
189
258
|
clearTimeout(this.reconnectTimer);
|
|
190
259
|
this.reconnectTimer = null;
|
|
191
260
|
}
|
|
261
|
+
this.connecting = false;
|
|
192
262
|
this.ws?.close();
|
|
193
263
|
this.ws = null;
|
|
194
264
|
this.projectId = null;
|
|
195
265
|
this.listeners.clear();
|
|
196
266
|
}
|
|
197
267
|
}
|
|
268
|
+
/** How long an unused connection stays open, in ms: a screen that comes
|
|
269
|
+
* straight back (navigating back, React's development double start)
|
|
270
|
+
* reuses it instead of opening another. */
|
|
271
|
+
KoolbaseRealtime.idleGraceMs = 1000;
|
|
@@ -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.12.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",
|