@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.
@@ -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) => UseRecordResult;
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
- const key = JSON.stringify([collection, id ?? '']);
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();
@@ -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 {};
@@ -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 > -1)
77
- callbacks.splice(i, 1);
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
- const token = await this.getToken();
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
- const ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
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
- constructor(db: RecordReader, collection: string, id: string);
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) => UseRecordResult;
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
- const key = JSON.stringify([collection, id ?? '']);
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();
@@ -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 {};
@@ -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 > -1)
74
- callbacks.splice(i, 1);
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
- const token = await this.getToken();
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
- const ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
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
- constructor(db: RecordReader, collection: string, id: string);
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.11.0",
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",