@koolbase/core 12.13.1 → 12.14.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.
@@ -67,3 +67,8 @@ export declare function optimisticallyInsert(userId: string, collection: string,
67
67
  * to get rid of one record.
68
68
  */
69
69
  export declare function removeFromCachedQueries(userId: string, collection: string, recordId: string): Promise<void>;
70
+ /**
71
+ * Applies an offline edit to the record wherever a cached result holds it, so
72
+ * a saved list shows what the user just changed rather than what it was.
73
+ */
74
+ export declare function updateInCachedQueries(userId: string, collection: string, recordId: string, data: Record<string, unknown>): Promise<void>;
@@ -14,6 +14,7 @@ exports.removeFromWriteQueue = removeFromWriteQueue;
14
14
  exports.incrementWriteRetry = incrementWriteRetry;
15
15
  exports.optimisticallyInsert = optimisticallyInsert;
16
16
  exports.removeFromCachedQueries = removeFromCachedQueries;
17
+ exports.updateInCachedQueries = updateInCachedQueries;
17
18
  const platform_js_1 = require("./platform.js");
18
19
  const CACHE_VERSION = 'v1';
19
20
  function cacheKey(userId, collection, queryHash) {
@@ -244,3 +245,33 @@ async function removeFromCachedQueries(userId, collection, recordId) {
244
245
  // ignore
245
246
  }
246
247
  }
248
+ /**
249
+ * Applies an offline edit to the record wherever a cached result holds it, so
250
+ * a saved list shows what the user just changed rather than what it was.
251
+ */
252
+ async function updateInCachedQueries(userId, collection, recordId, data) {
253
+ try {
254
+ const keys = await (0, platform_js_1.getPlatform)().storage.getAllKeys();
255
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
256
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
257
+ const raw = await (0, platform_js_1.getPlatform)().storage.getItem(key);
258
+ if (!raw)
259
+ continue;
260
+ const cached = JSON.parse(raw);
261
+ if (!Array.isArray(cached.records))
262
+ continue;
263
+ let changed = false;
264
+ cached.records = cached.records.map(r => {
265
+ if (r.id !== recordId)
266
+ return r;
267
+ changed = true;
268
+ return { ...r, data: { ...(r.data ?? {}), ...data } };
269
+ });
270
+ if (changed)
271
+ await (0, platform_js_1.getPlatform)().storage.setItem(key, JSON.stringify(cached));
272
+ }
273
+ }
274
+ catch {
275
+ // ignore
276
+ }
277
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Whether the device reports a network connection. A hint, not a promise that
3
+ * Koolbase is reachable: requests can still fail while online.
4
+ *
5
+ * 'unknown' until the platform first answers -- a real state, not a guess. An
6
+ * app should neither show "offline" nor run online-only logic on it.
7
+ */
8
+ export type ConnectivityState = 'unknown' | 'online' | 'offline';
9
+ /**
10
+ * The device's connectivity, as state an app can render: getState() for now,
11
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
12
+ */
13
+ export declare class KoolbaseConnectivity {
14
+ private state;
15
+ private readonly listeners;
16
+ private stop;
17
+ private heard;
18
+ /** Starts following the platform. Called once by Koolbase.initialize. */
19
+ start(): void;
20
+ getState(): ConnectivityState;
21
+ /** Called on every change. Returns the unsubscribe. */
22
+ subscribe(listener: () => void): () => void;
23
+ dispose(): void;
24
+ private set;
25
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KoolbaseConnectivity = void 0;
4
+ const platform_js_1 = require("./platform.js");
5
+ /**
6
+ * The device's connectivity, as state an app can render: getState() for now,
7
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
8
+ */
9
+ class KoolbaseConnectivity {
10
+ constructor() {
11
+ this.state = 'unknown';
12
+ this.listeners = new Set();
13
+ this.stop = null;
14
+ // A change event is fresher than the first read: once one arrives, a late
15
+ // answer to that read is ignored.
16
+ this.heard = false;
17
+ }
18
+ /** Starts following the platform. Called once by Koolbase.initialize. */
19
+ start() {
20
+ if (this.stop)
21
+ return;
22
+ const network = (0, platform_js_1.getPlatform)().network;
23
+ this.stop = network.onChange((online) => {
24
+ this.heard = true;
25
+ this.set(online ? 'online' : 'offline');
26
+ });
27
+ if (network.current) {
28
+ network.current().then((online) => {
29
+ if (!this.heard && online !== null && online !== undefined)
30
+ this.set(online ? 'online' : 'offline');
31
+ }, () => { });
32
+ }
33
+ }
34
+ getState() {
35
+ return this.state;
36
+ }
37
+ /** Called on every change. Returns the unsubscribe. */
38
+ subscribe(listener) {
39
+ this.listeners.add(listener);
40
+ return () => { this.listeners.delete(listener); };
41
+ }
42
+ dispose() {
43
+ this.stop?.();
44
+ this.stop = null;
45
+ this.listeners.clear();
46
+ }
47
+ set(next) {
48
+ if (next === this.state)
49
+ return;
50
+ this.state = next;
51
+ for (const listener of [...this.listeners]) {
52
+ try {
53
+ listener();
54
+ }
55
+ catch { /* one broken listener must not stop the rest */ }
56
+ }
57
+ }
58
+ }
59
+ exports.KoolbaseConnectivity = KoolbaseConnectivity;
@@ -119,6 +119,16 @@ export declare class KoolbaseDatabase {
119
119
  */
120
120
  batch(operations: BatchOp[]): Promise<BatchResult[]>;
121
121
  get(recordId: string): Promise<KoolbaseRecord>;
122
+ /**
123
+ * The device's saved copy of a record: what this device last read, with the
124
+ * changes queued on it applied. Never contacts the server. Null when there is
125
+ * none: never read here, or deleted on this device.
126
+ *
127
+ * For showing something at once while get() asks the server -- saved-first,
128
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
129
+ * the saved copy does not hold (empty strings here).
130
+ */
131
+ getSaved(recordId: string): Promise<KoolbaseRecord | null>;
122
132
  /**
123
133
  * Writes that could not be applied, waiting for a decision.
124
134
  *
@@ -134,6 +144,28 @@ export declare class KoolbaseDatabase {
134
144
  * moment matters. Snapshot, not a live handle; per-user.
135
145
  */
136
146
  pendingWrites(): Promise<PendingWrite[]>;
147
+ /**
148
+ * The pending writes now, and again whenever they change -- here, in another
149
+ * tab, or because the signed-in user changed. For a sync badge that reflects
150
+ * reality. null while signed out: per-user state has no answer then, and a
151
+ * fake empty list is how a badge says "all synced" while a user's changes
152
+ * sit queued. Returns the unsubscribe.
153
+ */
154
+ watchPendingWrites(listener: (writes: PendingWrite[] | null) => void): () => void;
155
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
156
+ watchConflicts(listener: (conflicts: KoolbaseConflict[] | null) => void): () => void;
157
+ /**
158
+ * The signed-in user changed: every watcher reads again, for the new user
159
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
160
+ */
161
+ sessionChanged(): void;
162
+ private readonly stateWatchers;
163
+ private offStateChange;
164
+ /**
165
+ * One watcher: reads at once, then on every change to the signed-in user's
166
+ * offline state. Reads can finish out of order; only the latest delivers.
167
+ */
168
+ private watchState;
137
169
  /**
138
170
  * Count and total over a whole collection, with the read rule applied
139
171
  * inside the query.
@@ -7,9 +7,10 @@ const database_errors_js_1 = require("./database-errors.js");
7
7
  const errors_js_1 = require("./errors.js");
8
8
  const cache_store_js_1 = require("./cache-store.js");
9
9
  const offline_state_js_1 = require("./offline-state.js");
10
+ const cache_store_js_2 = require("./cache-store.js");
10
11
  const conflict_js_1 = require("./conflict.js");
11
12
  const pending_write_js_1 = require("./pending-write.js");
12
- const cache_store_js_2 = require("./cache-store.js");
13
+ const cache_store_js_3 = require("./cache-store.js");
13
14
  const sync_engine_js_1 = require("./sync-engine.js");
14
15
  const record_js_1 = require("./record.js");
15
16
  const database_errors_js_2 = require("./database-errors.js");
@@ -45,6 +46,8 @@ function batchOpToWire(op) {
45
46
  }
46
47
  class KoolbaseDatabase {
47
48
  constructor(config, getUserId, getToken, onSessionExpired) {
49
+ this.stateWatchers = new Set();
50
+ this.offStateChange = null;
48
51
  /**
49
52
  * Resolves by id, reloading the stored conflict first.
50
53
  *
@@ -207,12 +210,12 @@ class KoolbaseDatabase {
207
210
  // query's identity, so both policies share one cache entry. onRefresh is
208
211
  // who to tell, not what to read, so it stays out the same way.
209
212
  const { cache: cachePolicy, onRefresh, ...queryOptions } = options;
210
- const queryHash = (0, cache_store_js_2.hashQuery)(collection, queryOptions);
211
- const cached = cachePolicy === 'network-only' ? null : await (0, cache_store_js_2.getCached)(userId, collection, queryHash);
213
+ const queryHash = (0, cache_store_js_3.hashQuery)(collection, queryOptions);
214
+ const cached = cachePolicy === 'network-only' ? null : await (0, cache_store_js_3.getCached)(userId, collection, queryHash);
212
215
  if (cached) {
213
216
  this.runQuery(collection, queryOptions)
214
217
  .then(async (result) => {
215
- await (0, cache_store_js_2.setCached)(userId, collection, queryHash, result);
218
+ await (0, cache_store_js_3.setCached)(userId, collection, queryHash, result);
216
219
  // Told outside the catch below, which is for the network: an
217
220
  // exception from the caller's callback is the caller's to see.
218
221
  if (onRefresh)
@@ -224,7 +227,7 @@ class KoolbaseDatabase {
224
227
  return { ...cached, isFromCache: true };
225
228
  }
226
229
  const result = await this.runQuery(collection, queryOptions);
227
- await (0, cache_store_js_2.setCached)(userId, collection, queryHash, result);
230
+ await (0, cache_store_js_3.setCached)(userId, collection, queryHash, result);
228
231
  return { ...result, isFromCache: false };
229
232
  }
230
233
  /**
@@ -233,7 +236,7 @@ class KoolbaseDatabase {
233
236
  * a server, or another user. The SDK's own writes already do this.
234
237
  */
235
238
  async invalidate(collection) {
236
- await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
239
+ await (0, cache_store_js_3.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
237
240
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
238
241
  }
239
242
  // ─── Insert (online-first with offline fallback) ───────────────────────────
@@ -257,7 +260,7 @@ class KoolbaseDatabase {
257
260
  // next query sees real data instead of a stale optimistic copy.
258
261
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
259
262
  const record = (0, record_js_1.recordFromWire)(raw);
260
- await (0, cache_store_js_2.invalidateCache)(userId, collection);
263
+ await (0, cache_store_js_3.invalidateCache)(userId, collection);
261
264
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
262
265
  // The response carries a fresh revision, so caching it keeps the
263
266
  // baseline current for whatever edits this record next.
@@ -293,7 +296,7 @@ class KoolbaseDatabase {
293
296
  createdAt: new Date().toISOString(),
294
297
  updatedAt: new Date().toISOString(),
295
298
  };
296
- await (0, cache_store_js_2.optimisticallyInsert)(userId, collection, optimisticRecord);
299
+ await (0, cache_store_js_3.optimisticallyInsert)(userId, collection, optimisticRecord);
297
300
  // No baseline: an insert has no prior state, and the record does not
298
301
  // exist on the server yet, so there is nothing to be conditional against.
299
302
  // An offline edit to it composes against this queued write instead.
@@ -332,7 +335,7 @@ class KoolbaseDatabase {
332
335
  const record = (0, record_js_1.recordFromWire)(body);
333
336
  // Keep the cache fresh, same intent as insert's post-success invalidate.
334
337
  const userId = this.getUserId() ?? 'anonymous';
335
- await (0, cache_store_js_2.invalidateCache)(userId, collection);
338
+ await (0, cache_store_js_3.invalidateCache)(userId, collection);
336
339
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
337
340
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
338
341
  return { record, created };
@@ -352,7 +355,7 @@ class KoolbaseDatabase {
352
355
  async deleteWhere(collection, filters) {
353
356
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
354
357
  const userId = this.getUserId() ?? 'anonymous';
355
- await (0, cache_store_js_2.invalidateCache)(userId, collection);
358
+ await (0, cache_store_js_3.invalidateCache)(userId, collection);
356
359
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
357
360
  return body.deleted ?? 0;
358
361
  }
@@ -416,7 +419,7 @@ class KoolbaseDatabase {
416
419
  }
417
420
  }
418
421
  for (const col of touched) {
419
- await (0, cache_store_js_2.invalidateCache)(userId, col);
422
+ await (0, cache_store_js_3.invalidateCache)(userId, col);
420
423
  void (0, query_refresh_js_1.refreshCollectionQueries)(col);
421
424
  }
422
425
  return results;
@@ -433,6 +436,29 @@ class KoolbaseDatabase {
433
436
  }
434
437
  return record;
435
438
  }
439
+ /**
440
+ * The device's saved copy of a record: what this device last read, with the
441
+ * changes queued on it applied. Never contacts the server. Null when there is
442
+ * none: never read here, or deleted on this device.
443
+ *
444
+ * For showing something at once while get() asks the server -- saved-first,
445
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
446
+ * the saved copy does not hold (empty strings here).
447
+ */
448
+ async getSaved(recordId) {
449
+ const userId = this.getUserId() ?? 'anonymous';
450
+ const base = await this.resolveBaseline(userId, recordId);
451
+ if (!base)
452
+ return null;
453
+ return {
454
+ id: recordId,
455
+ collection: base.collection,
456
+ data: { ...base.baseline },
457
+ createdAt: '',
458
+ updatedAt: '',
459
+ revision: base.revision,
460
+ };
461
+ }
436
462
  // ─── Conflicts ──────────────────────────────────────────────────────────────
437
463
  /**
438
464
  * Writes that could not be applied, waiting for a decision.
@@ -453,6 +479,69 @@ class KoolbaseDatabase {
453
479
  const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
454
480
  return pending.map(pending_write_js_1.toPendingWrite);
455
481
  }
482
+ /**
483
+ * The pending writes now, and again whenever they change -- here, in another
484
+ * tab, or because the signed-in user changed. For a sync badge that reflects
485
+ * reality. null while signed out: per-user state has no answer then, and a
486
+ * fake empty list is how a badge says "all synced" while a user's changes
487
+ * sit queued. Returns the unsubscribe.
488
+ */
489
+ watchPendingWrites(listener) {
490
+ return this.watchState(async (userId) => {
491
+ if (!userId)
492
+ return () => listener(null);
493
+ const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
494
+ const writes = pending.map(pending_write_js_1.toPendingWrite);
495
+ return () => listener(writes);
496
+ });
497
+ }
498
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
499
+ watchConflicts(listener) {
500
+ return this.watchState(async (userId) => {
501
+ if (!userId)
502
+ return () => listener(null);
503
+ const list = await this.conflicts();
504
+ return () => listener(list);
505
+ });
506
+ }
507
+ /**
508
+ * The signed-in user changed: every watcher reads again, for the new user
509
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
510
+ */
511
+ sessionChanged() {
512
+ for (const emit of [...this.stateWatchers])
513
+ emit();
514
+ }
515
+ /**
516
+ * One watcher: reads at once, then on every change to the signed-in user's
517
+ * offline state. Reads can finish out of order; only the latest delivers.
518
+ */
519
+ watchState(read) {
520
+ let latest = 0;
521
+ let active = true;
522
+ const emit = () => {
523
+ const mine = ++latest;
524
+ read(this.getUserId()).then((deliver) => { if (active && mine === latest)
525
+ deliver(); }, () => { });
526
+ };
527
+ this.stateWatchers.add(emit);
528
+ if (!this.offStateChange) {
529
+ this.offStateChange = (0, offline_state_js_1.onOfflineStateChange)((userId) => {
530
+ if (userId === this.getUserId())
531
+ for (const w of [...this.stateWatchers])
532
+ w();
533
+ });
534
+ }
535
+ emit();
536
+ return () => {
537
+ active = false;
538
+ this.stateWatchers.delete(emit);
539
+ if (this.stateWatchers.size === 0 && this.offStateChange) {
540
+ this.offStateChange();
541
+ this.offStateChange = null;
542
+ }
543
+ };
544
+ }
456
545
  /**
457
546
  * Count and total over a whole collection, with the read rule applied
458
547
  * inside the query.
@@ -630,7 +719,7 @@ class KoolbaseDatabase {
630
719
  }
631
720
  const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
632
721
  await this.settleConflict(c, landed ?? undefined, landedRevision);
633
- await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
722
+ await (0, cache_store_js_3.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
634
723
  void (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
635
724
  }
636
725
  // ─── Update (online-first with offline fallback) ───────────────────────────
@@ -667,8 +756,11 @@ class KoolbaseDatabase {
667
756
  for (const w of queued) {
668
757
  if (w.operation === 'insert')
669
758
  projected = { ...(w.data ?? {}) };
759
+ // An edit applies to the record as it was: the queued add's state, or
760
+ // -- first in a chain with no add -- the baseline the edit was made
761
+ // against. Starting from nothing dropped every field it did not touch.
670
762
  else if (w.operation === 'update')
671
- projected = { ...(projected ?? {}), ...(w.data ?? {}) };
763
+ projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
672
764
  else if (w.operation === 'delete')
673
765
  projected = null;
674
766
  }
@@ -737,6 +829,8 @@ class KoolbaseDatabase {
737
829
  });
738
830
  const merged = { ...base.baseline, ...data };
739
831
  await (0, cache_store_js_1.cacheRecord)(userId, base.collection, recordId, merged, base.revision);
832
+ // And in the saved lists, so they show what the user just changed.
833
+ await (0, cache_store_js_2.updateInCachedQueries)(userId, base.collection, recordId, data);
740
834
  // Optimistic: durable locally and queued to send, not yet accepted.
741
835
  return {
742
836
  id: recordId,
@@ -797,8 +891,9 @@ class KoolbaseDatabase {
797
891
  });
798
892
  // The queued write holds its own copy of the baseline, so removing the
799
893
  // cached record costs nothing and keeps local reads consistent with what
800
- // the user just did.
894
+ // the user just did -- saved lists included.
801
895
  await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
896
+ await (0, cache_store_js_2.removeFromCachedQueries)(userId, base.collection, recordId);
802
897
  }
803
898
  }
804
899
  // ─── Vectors ────────────────────────────────────────────────────────────────
@@ -17,6 +17,8 @@ export type { RecordState, RecordStatus } from './record-controller.js';
17
17
  export { KoolbaseFlags } from './flags.js';
18
18
  export { KoolbaseFunctions } from './functions.js';
19
19
  export { KoolbaseRealtime } from './realtime.js';
20
+ export { KoolbaseConnectivity } from './connectivity.js';
21
+ export type { ConnectivityState } from './connectivity.js';
20
22
  export { KoolbaseStorage } from './storage.js';
21
23
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
22
24
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
@@ -27,4 +29,4 @@ export { getOrCreateDeviceId } from './device-id.js';
27
29
  export { koolbaseSdkVersion } from './device-metadata.js';
28
30
  export { RestoreResult } from './types.js';
29
31
  export type { AuthStateListener, FetchLike, KoolbaseAuthStorage, ResendVerificationResult } from './types.js';
30
- export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
32
+ export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformBroadcast, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
package/dist/cjs/index.js CHANGED
@@ -19,7 +19,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
19
19
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
20
20
  };
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
22
+ exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseConnectivity = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
23
23
  __exportStar(require("./types.js"), exports);
24
24
  __exportStar(require("./errors.js"), exports);
25
25
  __exportStar(require("./network.js"), exports);
@@ -47,6 +47,8 @@ var functions_js_1 = require("./functions.js");
47
47
  Object.defineProperty(exports, "KoolbaseFunctions", { enumerable: true, get: function () { return functions_js_1.KoolbaseFunctions; } });
48
48
  var realtime_js_1 = require("./realtime.js");
49
49
  Object.defineProperty(exports, "KoolbaseRealtime", { enumerable: true, get: function () { return realtime_js_1.KoolbaseRealtime; } });
50
+ var connectivity_js_1 = require("./connectivity.js");
51
+ Object.defineProperty(exports, "KoolbaseConnectivity", { enumerable: true, get: function () { return connectivity_js_1.KoolbaseConnectivity; } });
50
52
  var storage_js_1 = require("./storage.js");
51
53
  Object.defineProperty(exports, "KoolbaseStorage", { enumerable: true, get: function () { return storage_js_1.KoolbaseStorage; } });
52
54
  var analytics_js_1 = require("./analytics.js");
@@ -119,3 +119,7 @@ export declare function migrateLegacyQueue(userId: string): Promise<void>;
119
119
  * be replayed through its projected states instead.
120
120
  */
121
121
  export declare function rebaseHeldWrites(state: OfflineState, recordId: string, resolved: Record<string, unknown>, revision: number | undefined): void;
122
+ type StateListener = (userId: string) => void;
123
+ /** Called with the user id whenever that user's offline state changes. */
124
+ export declare function onOfflineStateChange(listener: StateListener): () => void;
125
+ export {};
@@ -9,6 +9,7 @@ exports.mutateOfflineState = mutateOfflineState;
9
9
  exports.queueWrite = queueWrite;
10
10
  exports.migrateLegacyQueue = migrateLegacyQueue;
11
11
  exports.rebaseHeldWrites = rebaseHeldWrites;
12
+ exports.onOfflineStateChange = onOfflineStateChange;
12
13
  const platform_js_1 = require("./platform.js");
13
14
  const shared_js_1 = require("./shared.js");
14
15
  /**
@@ -142,6 +143,7 @@ async function mutateOfflineState(userId, mutate) {
142
143
  }
143
144
  await (0, platform_js_1.getPlatform)().storage.setItem(stateKey(userId), serialised);
144
145
  });
146
+ announce(userId);
145
147
  }
146
148
  /** Adds a write to the queue, under the user's lock. */
147
149
  async function queueWrite(userId, write) {
@@ -247,3 +249,37 @@ function rebaseHeldWrites(state, recordId, resolved, revision) {
247
249
  break;
248
250
  }
249
251
  }
252
+ // ─── Change notification ─────────────────────────────────────────────────────
253
+ const ANNOUNCE_PREFIX = 'koolbase:offline-state:';
254
+ function stateListeners() {
255
+ return (0, shared_js_1.shared)('offlineStateListeners', () => new Set());
256
+ }
257
+ /**
258
+ * Tells whoever is watching that a user's offline state changed -- here, and
259
+ * in every other copy of the SDK sharing this storage (other tabs). A badge in
260
+ * one tab must move when another tab's sync drains the queue.
261
+ */
262
+ function announce(userId) {
263
+ for (const listener of [...stateListeners()]) {
264
+ try {
265
+ listener(userId);
266
+ }
267
+ catch { /* one broken listener must not stop the rest */ }
268
+ }
269
+ try {
270
+ (0, platform_js_1.getPlatform)().broadcast?.post(ANNOUNCE_PREFIX + userId);
271
+ }
272
+ catch { /* best effort */ }
273
+ }
274
+ /** Called with the user id whenever that user's offline state changes. */
275
+ function onOfflineStateChange(listener) {
276
+ stateListeners().add(listener);
277
+ const off = (0, platform_js_1.getPlatform)().broadcast?.on((message) => {
278
+ if (message.startsWith(ANNOUNCE_PREFIX))
279
+ listener(message.slice(ANNOUNCE_PREFIX.length));
280
+ });
281
+ return () => {
282
+ stateListeners().delete(listener);
283
+ off?.();
284
+ };
285
+ }
@@ -12,6 +12,21 @@ export interface PlatformNetwork {
12
12
  * Koolbase is unreachable is handled the same as any failed request.
13
13
  */
14
14
  onChange(callback: (online: boolean) => void): () => void;
15
+ /**
16
+ * What the host believes right now: true online, false offline, null when
17
+ * it cannot tell. Optional: a host without it reports nothing until its
18
+ * first change, and connectivity stays 'unknown' until then.
19
+ */
20
+ current?(): Promise<boolean | null>;
21
+ }
22
+ /**
23
+ * A message to every other copy of the SDK sharing this storage -- other tabs
24
+ * of the same origin in a browser. Optional: one process (React Native) has no
25
+ * other copies to tell.
26
+ */
27
+ export interface PlatformBroadcast {
28
+ post(message: string): void;
29
+ on(callback: (message: string) => void): () => void;
15
30
  }
16
31
  export interface PlatformLifecycle {
17
32
  /** Subscribe to the app moving to the background. Used to flush analytics. */
@@ -54,6 +69,7 @@ export interface PlatformAdapter {
54
69
  lifecycle: PlatformLifecycle;
55
70
  info: PlatformInfo;
56
71
  locks: PlatformLocks;
72
+ broadcast?: PlatformBroadcast;
57
73
  /**
58
74
  * The host's best persistent store for the auth session, or null if it has
59
75
  * none worth the name. Used only when the app injects nothing through
@@ -3,6 +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, options?: {
6
+ export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>): (collection: string, id: string | null | undefined, options?: {
7
7
  live?: boolean;
8
8
  }) => UseRecordResult;
@@ -8,9 +8,14 @@ export interface RecordState {
8
8
  readonly error: unknown;
9
9
  /** True while refresh() runs. */
10
10
  readonly refreshing: boolean;
11
+ /**
12
+ * True while the record shown is the device's saved copy and the server has
13
+ * not confirmed it on this load. Stays true if the server cannot be reached.
14
+ */
15
+ readonly isSaved: boolean;
11
16
  }
12
17
  export declare const initialRecordState: RecordState;
13
- type RecordReader = Pick<KoolbaseDatabase, 'get'>;
18
+ type RecordReader = Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>;
14
19
  export declare class KoolbaseRecordController {
15
20
  readonly collection: string;
16
21
  readonly id: string;
@@ -31,6 +36,8 @@ export declare class KoolbaseRecordController {
31
36
  subscribe(listener: () => void): () => void;
32
37
  /** The first load. Call once; refresh() for later loads. */
33
38
  load(): Promise<void>;
39
+ /** The device's saved copy, shown at once while the server is asked. */
40
+ private showSaved;
34
41
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
35
42
  refresh(): Promise<void>;
36
43
  /** Stops all updates. A result still in flight is dropped when it lands. */
@@ -15,8 +15,10 @@
15
15
  // or landing after dispose(), is dropped.
16
16
  // - live (the option): realtime events for THIS record only. An update
17
17
  // re-reads it silently, once per burst; a delete is notFound at once.
18
- // Not yet: an offline fallback from the record cache, which keeps data and
19
- // revision but not createdAt or createdBy.
18
+ // - saved-first, never saved-only: the device's saved copy (db.getSaved)
19
+ // shows at once with isSaved true while the server is asked. Success
20
+ // replaces it (isSaved false); failure keeps it (isSaved stays true); not
21
+ // found is notFound. No saved copy: the ordinary loading/error states.
20
22
  Object.defineProperty(exports, "__esModule", { value: true });
21
23
  exports.KoolbaseRecordController = exports.initialRecordState = void 0;
22
24
  const database_errors_js_1 = require("./database-errors.js");
@@ -26,6 +28,7 @@ exports.initialRecordState = Object.freeze({
26
28
  record: null,
27
29
  error: null,
28
30
  refreshing: false,
31
+ isSaved: false,
29
32
  });
30
33
  class KoolbaseRecordController {
31
34
  constructor(db, collection, id, options = {}) {
@@ -61,8 +64,27 @@ class KoolbaseRecordController {
61
64
  if (this.live && this.id) {
62
65
  this.unlive ?? (this.unlive = (0, live_source_js_1.liveSource)()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
63
66
  }
67
+ await this.showSaved();
64
68
  await this.fetch();
65
69
  }
70
+ /** The device's saved copy, shown at once while the server is asked. */
71
+ async showSaved() {
72
+ if (!this.id || !this.db.getSaved)
73
+ return;
74
+ const gen = this.generation;
75
+ let saved = null;
76
+ try {
77
+ saved = await this.db.getSaved(this.id);
78
+ }
79
+ catch {
80
+ return;
81
+ }
82
+ if (!saved || this.isStale(gen) || this.state.status !== 'loading')
83
+ return;
84
+ if (saved.collection !== undefined && saved.collection !== this.collection)
85
+ return;
86
+ this.set({ status: 'loaded', record: saved, error: null, isSaved: true });
87
+ }
66
88
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
67
89
  async refresh() {
68
90
  if (this.disposed)
@@ -102,7 +124,7 @@ class KoolbaseRecordController {
102
124
  this.liveTimer = null;
103
125
  }
104
126
  this.generation += 1;
105
- this.set({ status: 'notFound', record: null, error: null, refreshing: false });
127
+ this.set({ status: 'notFound', record: null, error: null, refreshing: false, isSaved: false });
106
128
  return;
107
129
  }
108
130
  if (this.liveTimer)
@@ -117,7 +139,7 @@ class KoolbaseRecordController {
117
139
  async fetch() {
118
140
  const gen = ++this.generation;
119
141
  if (!this.id) {
120
- this.set({ status: 'notFound', record: null, error: null });
142
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
121
143
  return gen;
122
144
  }
123
145
  try {
@@ -125,17 +147,17 @@ class KoolbaseRecordController {
125
147
  if (this.isStale(gen))
126
148
  return gen;
127
149
  if (record.collection !== undefined && record.collection !== this.collection) {
128
- this.set({ status: 'notFound', record: null, error: null });
150
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
129
151
  }
130
152
  else {
131
- this.set({ status: 'loaded', record, error: null });
153
+ this.set({ status: 'loaded', record, error: null, isSaved: false });
132
154
  }
133
155
  }
134
156
  catch (error) {
135
157
  if (this.isStale(gen))
136
158
  return gen;
137
159
  if (error instanceof database_errors_js_1.KoolbaseNotFoundError) {
138
- this.set({ status: 'notFound', record: null, error: null });
160
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
139
161
  }
140
162
  else if (this.state.record === null) {
141
163
  this.set({ status: 'error', error });
@@ -1,4 +1,5 @@
1
1
  interface Slot {
2
+ offlineStateListeners: Set<(userId: string) => void>;
2
3
  platform?: unknown;
3
4
  locks?: Map<string, Promise<unknown>>;
4
5
  }
@@ -67,3 +67,8 @@ export declare function optimisticallyInsert(userId: string, collection: string,
67
67
  * to get rid of one record.
68
68
  */
69
69
  export declare function removeFromCachedQueries(userId: string, collection: string, recordId: string): Promise<void>;
70
+ /**
71
+ * Applies an offline edit to the record wherever a cached result holds it, so
72
+ * a saved list shows what the user just changed rather than what it was.
73
+ */
74
+ export declare function updateInCachedQueries(userId: string, collection: string, recordId: string, data: Record<string, unknown>): Promise<void>;
@@ -228,3 +228,33 @@ export async function removeFromCachedQueries(userId, collection, recordId) {
228
228
  // ignore
229
229
  }
230
230
  }
231
+ /**
232
+ * Applies an offline edit to the record wherever a cached result holds it, so
233
+ * a saved list shows what the user just changed rather than what it was.
234
+ */
235
+ export async function updateInCachedQueries(userId, collection, recordId, data) {
236
+ try {
237
+ const keys = await getPlatform().storage.getAllKeys();
238
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
239
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
240
+ const raw = await getPlatform().storage.getItem(key);
241
+ if (!raw)
242
+ continue;
243
+ const cached = JSON.parse(raw);
244
+ if (!Array.isArray(cached.records))
245
+ continue;
246
+ let changed = false;
247
+ cached.records = cached.records.map(r => {
248
+ if (r.id !== recordId)
249
+ return r;
250
+ changed = true;
251
+ return { ...r, data: { ...(r.data ?? {}), ...data } };
252
+ });
253
+ if (changed)
254
+ await getPlatform().storage.setItem(key, JSON.stringify(cached));
255
+ }
256
+ }
257
+ catch {
258
+ // ignore
259
+ }
260
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Whether the device reports a network connection. A hint, not a promise that
3
+ * Koolbase is reachable: requests can still fail while online.
4
+ *
5
+ * 'unknown' until the platform first answers -- a real state, not a guess. An
6
+ * app should neither show "offline" nor run online-only logic on it.
7
+ */
8
+ export type ConnectivityState = 'unknown' | 'online' | 'offline';
9
+ /**
10
+ * The device's connectivity, as state an app can render: getState() for now,
11
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
12
+ */
13
+ export declare class KoolbaseConnectivity {
14
+ private state;
15
+ private readonly listeners;
16
+ private stop;
17
+ private heard;
18
+ /** Starts following the platform. Called once by Koolbase.initialize. */
19
+ start(): void;
20
+ getState(): ConnectivityState;
21
+ /** Called on every change. Returns the unsubscribe. */
22
+ subscribe(listener: () => void): () => void;
23
+ dispose(): void;
24
+ private set;
25
+ }
@@ -0,0 +1,55 @@
1
+ import { getPlatform } from './platform.js';
2
+ /**
3
+ * The device's connectivity, as state an app can render: getState() for now,
4
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
5
+ */
6
+ export class KoolbaseConnectivity {
7
+ constructor() {
8
+ this.state = 'unknown';
9
+ this.listeners = new Set();
10
+ this.stop = null;
11
+ // A change event is fresher than the first read: once one arrives, a late
12
+ // answer to that read is ignored.
13
+ this.heard = false;
14
+ }
15
+ /** Starts following the platform. Called once by Koolbase.initialize. */
16
+ start() {
17
+ if (this.stop)
18
+ return;
19
+ const network = getPlatform().network;
20
+ this.stop = network.onChange((online) => {
21
+ this.heard = true;
22
+ this.set(online ? 'online' : 'offline');
23
+ });
24
+ if (network.current) {
25
+ network.current().then((online) => {
26
+ if (!this.heard && online !== null && online !== undefined)
27
+ this.set(online ? 'online' : 'offline');
28
+ }, () => { });
29
+ }
30
+ }
31
+ getState() {
32
+ return this.state;
33
+ }
34
+ /** Called on every change. Returns the unsubscribe. */
35
+ subscribe(listener) {
36
+ this.listeners.add(listener);
37
+ return () => { this.listeners.delete(listener); };
38
+ }
39
+ dispose() {
40
+ this.stop?.();
41
+ this.stop = null;
42
+ this.listeners.clear();
43
+ }
44
+ set(next) {
45
+ if (next === this.state)
46
+ return;
47
+ this.state = next;
48
+ for (const listener of [...this.listeners]) {
49
+ try {
50
+ listener();
51
+ }
52
+ catch { /* one broken listener must not stop the rest */ }
53
+ }
54
+ }
55
+ }
@@ -119,6 +119,16 @@ export declare class KoolbaseDatabase {
119
119
  */
120
120
  batch(operations: BatchOp[]): Promise<BatchResult[]>;
121
121
  get(recordId: string): Promise<KoolbaseRecord>;
122
+ /**
123
+ * The device's saved copy of a record: what this device last read, with the
124
+ * changes queued on it applied. Never contacts the server. Null when there is
125
+ * none: never read here, or deleted on this device.
126
+ *
127
+ * For showing something at once while get() asks the server -- saved-first,
128
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
129
+ * the saved copy does not hold (empty strings here).
130
+ */
131
+ getSaved(recordId: string): Promise<KoolbaseRecord | null>;
122
132
  /**
123
133
  * Writes that could not be applied, waiting for a decision.
124
134
  *
@@ -134,6 +144,28 @@ export declare class KoolbaseDatabase {
134
144
  * moment matters. Snapshot, not a live handle; per-user.
135
145
  */
136
146
  pendingWrites(): Promise<PendingWrite[]>;
147
+ /**
148
+ * The pending writes now, and again whenever they change -- here, in another
149
+ * tab, or because the signed-in user changed. For a sync badge that reflects
150
+ * reality. null while signed out: per-user state has no answer then, and a
151
+ * fake empty list is how a badge says "all synced" while a user's changes
152
+ * sit queued. Returns the unsubscribe.
153
+ */
154
+ watchPendingWrites(listener: (writes: PendingWrite[] | null) => void): () => void;
155
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
156
+ watchConflicts(listener: (conflicts: KoolbaseConflict[] | null) => void): () => void;
157
+ /**
158
+ * The signed-in user changed: every watcher reads again, for the new user
159
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
160
+ */
161
+ sessionChanged(): void;
162
+ private readonly stateWatchers;
163
+ private offStateChange;
164
+ /**
165
+ * One watcher: reads at once, then on every change to the signed-in user's
166
+ * offline state. Reads can finish out of order; only the latest delivers.
167
+ */
168
+ private watchState;
137
169
  /**
138
170
  * Count and total over a whole collection, with the read rule applied
139
171
  * inside the query.
@@ -3,7 +3,8 @@ import { refreshCollectionQueries } from './query-refresh.js';
3
3
  import { KoolbaseRevisionMismatchError } from './database-errors.js';
4
4
  import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
5
5
  import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
6
- import { readOfflineState, mutateOfflineState, queueWrite, rebaseHeldWrites, } from './offline-state.js';
6
+ import { readOfflineState, mutateOfflineState, queueWrite, rebaseHeldWrites, onOfflineStateChange, } from './offline-state.js';
7
+ import { removeFromCachedQueries, updateInCachedQueries } from './cache-store.js';
7
8
  import { KoolbaseConflict } from './conflict.js';
8
9
  import { toPendingWrite } from './pending-write.js';
9
10
  import { getCached, setCached, invalidateCache, optimisticallyInsert, hashQuery, } from './cache-store.js';
@@ -42,6 +43,8 @@ function batchOpToWire(op) {
42
43
  }
43
44
  export class KoolbaseDatabase {
44
45
  constructor(config, getUserId, getToken, onSessionExpired) {
46
+ this.stateWatchers = new Set();
47
+ this.offStateChange = null;
45
48
  /**
46
49
  * Resolves by id, reloading the stored conflict first.
47
50
  *
@@ -430,6 +433,29 @@ export class KoolbaseDatabase {
430
433
  }
431
434
  return record;
432
435
  }
436
+ /**
437
+ * The device's saved copy of a record: what this device last read, with the
438
+ * changes queued on it applied. Never contacts the server. Null when there is
439
+ * none: never read here, or deleted on this device.
440
+ *
441
+ * For showing something at once while get() asks the server -- saved-first,
442
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
443
+ * the saved copy does not hold (empty strings here).
444
+ */
445
+ async getSaved(recordId) {
446
+ const userId = this.getUserId() ?? 'anonymous';
447
+ const base = await this.resolveBaseline(userId, recordId);
448
+ if (!base)
449
+ return null;
450
+ return {
451
+ id: recordId,
452
+ collection: base.collection,
453
+ data: { ...base.baseline },
454
+ createdAt: '',
455
+ updatedAt: '',
456
+ revision: base.revision,
457
+ };
458
+ }
433
459
  // ─── Conflicts ──────────────────────────────────────────────────────────────
434
460
  /**
435
461
  * Writes that could not be applied, waiting for a decision.
@@ -450,6 +476,69 @@ export class KoolbaseDatabase {
450
476
  const { pending } = await readOfflineState(userId);
451
477
  return pending.map(toPendingWrite);
452
478
  }
479
+ /**
480
+ * The pending writes now, and again whenever they change -- here, in another
481
+ * tab, or because the signed-in user changed. For a sync badge that reflects
482
+ * reality. null while signed out: per-user state has no answer then, and a
483
+ * fake empty list is how a badge says "all synced" while a user's changes
484
+ * sit queued. Returns the unsubscribe.
485
+ */
486
+ watchPendingWrites(listener) {
487
+ return this.watchState(async (userId) => {
488
+ if (!userId)
489
+ return () => listener(null);
490
+ const { pending } = await readOfflineState(userId);
491
+ const writes = pending.map(toPendingWrite);
492
+ return () => listener(writes);
493
+ });
494
+ }
495
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
496
+ watchConflicts(listener) {
497
+ return this.watchState(async (userId) => {
498
+ if (!userId)
499
+ return () => listener(null);
500
+ const list = await this.conflicts();
501
+ return () => listener(list);
502
+ });
503
+ }
504
+ /**
505
+ * The signed-in user changed: every watcher reads again, for the new user
506
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
507
+ */
508
+ sessionChanged() {
509
+ for (const emit of [...this.stateWatchers])
510
+ emit();
511
+ }
512
+ /**
513
+ * One watcher: reads at once, then on every change to the signed-in user's
514
+ * offline state. Reads can finish out of order; only the latest delivers.
515
+ */
516
+ watchState(read) {
517
+ let latest = 0;
518
+ let active = true;
519
+ const emit = () => {
520
+ const mine = ++latest;
521
+ read(this.getUserId()).then((deliver) => { if (active && mine === latest)
522
+ deliver(); }, () => { });
523
+ };
524
+ this.stateWatchers.add(emit);
525
+ if (!this.offStateChange) {
526
+ this.offStateChange = onOfflineStateChange((userId) => {
527
+ if (userId === this.getUserId())
528
+ for (const w of [...this.stateWatchers])
529
+ w();
530
+ });
531
+ }
532
+ emit();
533
+ return () => {
534
+ active = false;
535
+ this.stateWatchers.delete(emit);
536
+ if (this.stateWatchers.size === 0 && this.offStateChange) {
537
+ this.offStateChange();
538
+ this.offStateChange = null;
539
+ }
540
+ };
541
+ }
453
542
  /**
454
543
  * Count and total over a whole collection, with the read rule applied
455
544
  * inside the query.
@@ -664,8 +753,11 @@ export class KoolbaseDatabase {
664
753
  for (const w of queued) {
665
754
  if (w.operation === 'insert')
666
755
  projected = { ...(w.data ?? {}) };
756
+ // An edit applies to the record as it was: the queued add's state, or
757
+ // -- first in a chain with no add -- the baseline the edit was made
758
+ // against. Starting from nothing dropped every field it did not touch.
667
759
  else if (w.operation === 'update')
668
- projected = { ...(projected ?? {}), ...(w.data ?? {}) };
760
+ projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
669
761
  else if (w.operation === 'delete')
670
762
  projected = null;
671
763
  }
@@ -734,6 +826,8 @@ export class KoolbaseDatabase {
734
826
  });
735
827
  const merged = { ...base.baseline, ...data };
736
828
  await cacheRecord(userId, base.collection, recordId, merged, base.revision);
829
+ // And in the saved lists, so they show what the user just changed.
830
+ await updateInCachedQueries(userId, base.collection, recordId, data);
737
831
  // Optimistic: durable locally and queued to send, not yet accepted.
738
832
  return {
739
833
  id: recordId,
@@ -794,8 +888,9 @@ export class KoolbaseDatabase {
794
888
  });
795
889
  // The queued write holds its own copy of the baseline, so removing the
796
890
  // cached record costs nothing and keeps local reads consistent with what
797
- // the user just did.
891
+ // the user just did -- saved lists included.
798
892
  await removeCachedRecord(userId, recordId);
893
+ await removeFromCachedQueries(userId, base.collection, recordId);
799
894
  }
800
895
  }
801
896
  // ─── Vectors ────────────────────────────────────────────────────────────────
@@ -17,6 +17,8 @@ export type { RecordState, RecordStatus } from './record-controller.js';
17
17
  export { KoolbaseFlags } from './flags.js';
18
18
  export { KoolbaseFunctions } from './functions.js';
19
19
  export { KoolbaseRealtime } from './realtime.js';
20
+ export { KoolbaseConnectivity } from './connectivity.js';
21
+ export type { ConnectivityState } from './connectivity.js';
20
22
  export { KoolbaseStorage } from './storage.js';
21
23
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
22
24
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
@@ -27,4 +29,4 @@ export { getOrCreateDeviceId } from './device-id.js';
27
29
  export { koolbaseSdkVersion } from './device-metadata.js';
28
30
  export { RestoreResult } from './types.js';
29
31
  export type { AuthStateListener, FetchLike, KoolbaseAuthStorage, ResendVerificationResult } from './types.js';
30
- export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
32
+ export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformBroadcast, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
package/dist/esm/index.js CHANGED
@@ -20,6 +20,7 @@ export { KoolbaseRecordController, initialRecordState } from './record-controlle
20
20
  export { KoolbaseFlags } from './flags.js';
21
21
  export { KoolbaseFunctions } from './functions.js';
22
22
  export { KoolbaseRealtime } from './realtime.js';
23
+ export { KoolbaseConnectivity } from './connectivity.js';
23
24
  export { KoolbaseStorage } from './storage.js';
24
25
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
25
26
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
@@ -119,3 +119,7 @@ export declare function migrateLegacyQueue(userId: string): Promise<void>;
119
119
  * be replayed through its projected states instead.
120
120
  */
121
121
  export declare function rebaseHeldWrites(state: OfflineState, recordId: string, resolved: Record<string, unknown>, revision: number | undefined): void;
122
+ type StateListener = (userId: string) => void;
123
+ /** Called with the user id whenever that user's offline state changes. */
124
+ export declare function onOfflineStateChange(listener: StateListener): () => void;
125
+ export {};
@@ -130,6 +130,7 @@ export async function mutateOfflineState(userId, mutate) {
130
130
  }
131
131
  await getPlatform().storage.setItem(stateKey(userId), serialised);
132
132
  });
133
+ announce(userId);
133
134
  }
134
135
  /** Adds a write to the queue, under the user's lock. */
135
136
  export async function queueWrite(userId, write) {
@@ -235,3 +236,37 @@ export function rebaseHeldWrites(state, recordId, resolved, revision) {
235
236
  break;
236
237
  }
237
238
  }
239
+ // ─── Change notification ─────────────────────────────────────────────────────
240
+ const ANNOUNCE_PREFIX = 'koolbase:offline-state:';
241
+ function stateListeners() {
242
+ return shared('offlineStateListeners', () => new Set());
243
+ }
244
+ /**
245
+ * Tells whoever is watching that a user's offline state changed -- here, and
246
+ * in every other copy of the SDK sharing this storage (other tabs). A badge in
247
+ * one tab must move when another tab's sync drains the queue.
248
+ */
249
+ function announce(userId) {
250
+ for (const listener of [...stateListeners()]) {
251
+ try {
252
+ listener(userId);
253
+ }
254
+ catch { /* one broken listener must not stop the rest */ }
255
+ }
256
+ try {
257
+ getPlatform().broadcast?.post(ANNOUNCE_PREFIX + userId);
258
+ }
259
+ catch { /* best effort */ }
260
+ }
261
+ /** Called with the user id whenever that user's offline state changes. */
262
+ export function onOfflineStateChange(listener) {
263
+ stateListeners().add(listener);
264
+ const off = getPlatform().broadcast?.on((message) => {
265
+ if (message.startsWith(ANNOUNCE_PREFIX))
266
+ listener(message.slice(ANNOUNCE_PREFIX.length));
267
+ });
268
+ return () => {
269
+ stateListeners().delete(listener);
270
+ off?.();
271
+ };
272
+ }
@@ -12,6 +12,21 @@ export interface PlatformNetwork {
12
12
  * Koolbase is unreachable is handled the same as any failed request.
13
13
  */
14
14
  onChange(callback: (online: boolean) => void): () => void;
15
+ /**
16
+ * What the host believes right now: true online, false offline, null when
17
+ * it cannot tell. Optional: a host without it reports nothing until its
18
+ * first change, and connectivity stays 'unknown' until then.
19
+ */
20
+ current?(): Promise<boolean | null>;
21
+ }
22
+ /**
23
+ * A message to every other copy of the SDK sharing this storage -- other tabs
24
+ * of the same origin in a browser. Optional: one process (React Native) has no
25
+ * other copies to tell.
26
+ */
27
+ export interface PlatformBroadcast {
28
+ post(message: string): void;
29
+ on(callback: (message: string) => void): () => void;
15
30
  }
16
31
  export interface PlatformLifecycle {
17
32
  /** Subscribe to the app moving to the background. Used to flush analytics. */
@@ -54,6 +69,7 @@ export interface PlatformAdapter {
54
69
  lifecycle: PlatformLifecycle;
55
70
  info: PlatformInfo;
56
71
  locks: PlatformLocks;
72
+ broadcast?: PlatformBroadcast;
57
73
  /**
58
74
  * The host's best persistent store for the auth session, or null if it has
59
75
  * none worth the name. Used only when the app injects nothing through
@@ -3,6 +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, options?: {
6
+ export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>): (collection: string, id: string | null | undefined, options?: {
7
7
  live?: boolean;
8
8
  }) => UseRecordResult;
@@ -8,9 +8,14 @@ export interface RecordState {
8
8
  readonly error: unknown;
9
9
  /** True while refresh() runs. */
10
10
  readonly refreshing: boolean;
11
+ /**
12
+ * True while the record shown is the device's saved copy and the server has
13
+ * not confirmed it on this load. Stays true if the server cannot be reached.
14
+ */
15
+ readonly isSaved: boolean;
11
16
  }
12
17
  export declare const initialRecordState: RecordState;
13
- type RecordReader = Pick<KoolbaseDatabase, 'get'>;
18
+ type RecordReader = Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>;
14
19
  export declare class KoolbaseRecordController {
15
20
  readonly collection: string;
16
21
  readonly id: string;
@@ -31,6 +36,8 @@ export declare class KoolbaseRecordController {
31
36
  subscribe(listener: () => void): () => void;
32
37
  /** The first load. Call once; refresh() for later loads. */
33
38
  load(): Promise<void>;
39
+ /** The device's saved copy, shown at once while the server is asked. */
40
+ private showSaved;
34
41
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
35
42
  refresh(): Promise<void>;
36
43
  /** Stops all updates. A result still in flight is dropped when it lands. */
@@ -14,8 +14,10 @@
14
14
  // or landing after dispose(), is dropped.
15
15
  // - live (the option): realtime events for THIS record only. An update
16
16
  // re-reads it silently, once per burst; a delete is notFound at once.
17
- // Not yet: an offline fallback from the record cache, which keeps data and
18
- // revision but not createdAt or createdBy.
17
+ // - saved-first, never saved-only: the device's saved copy (db.getSaved)
18
+ // shows at once with isSaved true while the server is asked. Success
19
+ // replaces it (isSaved false); failure keeps it (isSaved stays true); not
20
+ // found is notFound. No saved copy: the ordinary loading/error states.
19
21
  import { KoolbaseNotFoundError } from './database-errors.js';
20
22
  import { liveSource } from './live-source.js';
21
23
  export const initialRecordState = Object.freeze({
@@ -23,6 +25,7 @@ export const initialRecordState = Object.freeze({
23
25
  record: null,
24
26
  error: null,
25
27
  refreshing: false,
28
+ isSaved: false,
26
29
  });
27
30
  export class KoolbaseRecordController {
28
31
  constructor(db, collection, id, options = {}) {
@@ -58,8 +61,27 @@ export class KoolbaseRecordController {
58
61
  if (this.live && this.id) {
59
62
  this.unlive ?? (this.unlive = liveSource()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
60
63
  }
64
+ await this.showSaved();
61
65
  await this.fetch();
62
66
  }
67
+ /** The device's saved copy, shown at once while the server is asked. */
68
+ async showSaved() {
69
+ if (!this.id || !this.db.getSaved)
70
+ return;
71
+ const gen = this.generation;
72
+ let saved = null;
73
+ try {
74
+ saved = await this.db.getSaved(this.id);
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ if (!saved || this.isStale(gen) || this.state.status !== 'loading')
80
+ return;
81
+ if (saved.collection !== undefined && saved.collection !== this.collection)
82
+ return;
83
+ this.set({ status: 'loaded', record: saved, error: null, isSaved: true });
84
+ }
63
85
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
64
86
  async refresh() {
65
87
  if (this.disposed)
@@ -99,7 +121,7 @@ export class KoolbaseRecordController {
99
121
  this.liveTimer = null;
100
122
  }
101
123
  this.generation += 1;
102
- this.set({ status: 'notFound', record: null, error: null, refreshing: false });
124
+ this.set({ status: 'notFound', record: null, error: null, refreshing: false, isSaved: false });
103
125
  return;
104
126
  }
105
127
  if (this.liveTimer)
@@ -114,7 +136,7 @@ export class KoolbaseRecordController {
114
136
  async fetch() {
115
137
  const gen = ++this.generation;
116
138
  if (!this.id) {
117
- this.set({ status: 'notFound', record: null, error: null });
139
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
118
140
  return gen;
119
141
  }
120
142
  try {
@@ -122,17 +144,17 @@ export class KoolbaseRecordController {
122
144
  if (this.isStale(gen))
123
145
  return gen;
124
146
  if (record.collection !== undefined && record.collection !== this.collection) {
125
- this.set({ status: 'notFound', record: null, error: null });
147
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
126
148
  }
127
149
  else {
128
- this.set({ status: 'loaded', record, error: null });
150
+ this.set({ status: 'loaded', record, error: null, isSaved: false });
129
151
  }
130
152
  }
131
153
  catch (error) {
132
154
  if (this.isStale(gen))
133
155
  return gen;
134
156
  if (error instanceof KoolbaseNotFoundError) {
135
- this.set({ status: 'notFound', record: null, error: null });
157
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
136
158
  }
137
159
  else if (this.state.record === null) {
138
160
  this.set({ status: 'error', error });
@@ -1,4 +1,5 @@
1
1
  interface Slot {
2
+ offlineStateListeners: Set<(userId: string) => void>;
2
3
  platform?: unknown;
3
4
  locks?: Map<string, Promise<unknown>>;
4
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.13.1",
3
+ "version": "12.14.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",