@koolbase/core 12.13.1 → 12.15.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.
Files changed (49) hide show
  1. package/dist/cjs/auth.d.ts +31 -1
  2. package/dist/cjs/auth.js +167 -20
  3. package/dist/cjs/cache-store.d.ts +5 -0
  4. package/dist/cjs/cache-store.js +31 -0
  5. package/dist/cjs/connectivity.d.ts +25 -0
  6. package/dist/cjs/connectivity.js +59 -0
  7. package/dist/cjs/database.d.ts +32 -0
  8. package/dist/cjs/database.js +111 -16
  9. package/dist/cjs/fiscal.js +2 -2
  10. package/dist/cjs/functions.js +2 -2
  11. package/dist/cjs/index.d.ts +3 -1
  12. package/dist/cjs/index.js +3 -1
  13. package/dist/cjs/network.d.ts +14 -0
  14. package/dist/cjs/network.js +60 -0
  15. package/dist/cjs/offline-state.d.ts +4 -0
  16. package/dist/cjs/offline-state.js +36 -0
  17. package/dist/cjs/platform.d.ts +16 -0
  18. package/dist/cjs/react/use-record.d.ts +1 -1
  19. package/dist/cjs/record-controller.d.ts +8 -1
  20. package/dist/cjs/record-controller.js +29 -7
  21. package/dist/cjs/shared.d.ts +1 -0
  22. package/dist/cjs/storage.js +11 -11
  23. package/dist/cjs/sync-engine.js +3 -3
  24. package/dist/cjs/types.d.ts +7 -0
  25. package/dist/esm/auth.d.ts +31 -1
  26. package/dist/esm/auth.js +168 -21
  27. package/dist/esm/cache-store.d.ts +5 -0
  28. package/dist/esm/cache-store.js +30 -0
  29. package/dist/esm/connectivity.d.ts +25 -0
  30. package/dist/esm/connectivity.js +55 -0
  31. package/dist/esm/database.d.ts +32 -0
  32. package/dist/esm/database.js +101 -6
  33. package/dist/esm/fiscal.js +3 -3
  34. package/dist/esm/functions.js +3 -3
  35. package/dist/esm/index.d.ts +3 -1
  36. package/dist/esm/index.js +1 -0
  37. package/dist/esm/network.d.ts +14 -0
  38. package/dist/esm/network.js +58 -0
  39. package/dist/esm/offline-state.d.ts +4 -0
  40. package/dist/esm/offline-state.js +35 -0
  41. package/dist/esm/platform.d.ts +16 -0
  42. package/dist/esm/react/use-record.d.ts +1 -1
  43. package/dist/esm/record-controller.d.ts +8 -1
  44. package/dist/esm/record-controller.js +29 -7
  45. package/dist/esm/shared.d.ts +1 -0
  46. package/dist/esm/storage.js +12 -12
  47. package/dist/esm/sync-engine.js +4 -4
  48. package/dist/esm/types.d.ts +7 -0
  49. package/package.json +1 -1
@@ -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
  *
@@ -94,7 +97,7 @@ class KoolbaseDatabase {
94
97
  };
95
98
  }
96
99
  async request(method, path, body) {
97
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}${path}`, {
100
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}${path}`, {
98
101
  method,
99
102
  headers: await this.buildHeaders(),
100
103
  body: body ? JSON.stringify(body) : undefined,
@@ -132,7 +135,7 @@ class KoolbaseDatabase {
132
135
  * none of them clearing a rejected session. One path, two shapes of result.
133
136
  */
134
137
  async requestWithStatus(method, path, body) {
135
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}${path}`, {
138
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}${path}`, {
136
139
  method,
137
140
  headers: await this.buildHeaders(),
138
141
  body: body ? JSON.stringify(body) : undefined,
@@ -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 ────────────────────────────────────────────────────────────────
@@ -113,7 +113,7 @@ class KoolbaseFiscal {
113
113
  const controller = new AbortController();
114
114
  const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 30000);
115
115
  try {
116
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/fiscal/submit`, {
116
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/fiscal/submit`, {
117
117
  method: 'POST',
118
118
  headers: await this.buildHeaders(),
119
119
  signal: controller.signal,
@@ -151,7 +151,7 @@ class KoolbaseFiscal {
151
151
  device_id: params.deviceId,
152
152
  client_ref: params.clientRef,
153
153
  });
154
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/fiscal/status?${q.toString()}`, { method: 'GET', headers: await this.buildHeaders(), signal: controller.signal });
154
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/fiscal/status?${q.toString()}`, { method: 'GET', headers: await this.buildHeaders(), signal: controller.signal });
155
155
  return await this.decode(res);
156
156
  }
157
157
  catch (e) {
@@ -14,7 +14,7 @@ class KoolbaseFunctions {
14
14
  // ─── Deploy ────────────────────────────────────────────────────────────────
15
15
  async deploy(options) {
16
16
  const runtime = options.runtime ?? types_js_1.FunctionRuntime.Deno;
17
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/functions/deploy`, {
17
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/functions/deploy`, {
18
18
  method: 'POST',
19
19
  headers: {
20
20
  'Content-Type': 'application/json',
@@ -58,7 +58,7 @@ class KoolbaseFunctions {
58
58
  if (userToken) {
59
59
  headers['Authorization'] = `Bearer ${userToken}`;
60
60
  }
61
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/functions/${name}`, {
61
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/functions/${name}`, {
62
62
  method: 'POST',
63
63
  headers,
64
64
  body: JSON.stringify({ body: body ?? {} }),
@@ -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");
@@ -19,3 +19,17 @@ export declare class KoolbaseNetworkError extends TypeError {
19
19
  }
20
20
  /** fetch, with network failures turned into a KoolbaseNetworkError. */
21
21
  export declare function koolbaseFetch(input: string, init?: RequestInit): Promise<Response>;
22
+ type Renewal = (refusedToken: string) => Promise<string | null>;
23
+ /** Installed by Koolbase.initialize: renews the session a request was refused with. */
24
+ export declare function setSessionRenewal(fn: Renewal | null): void;
25
+ /**
26
+ * koolbaseFetch for a data request. A request refused as unauthenticated
27
+ * (401) while carrying the signed-in person's token is the session needing
28
+ * renewal, not ending: the access token expired early, the clock is off, or
29
+ * another tab renewed it. The session is renewed once and the request sent
30
+ * again with the new token, so the caller never sees the first refusal.
31
+ * Only when renewal is refused does the 401 reach the caller, and with it the
32
+ * client's onSessionExpired, which ends the session.
33
+ */
34
+ export declare function sessionFetch(input: string, init?: RequestInit): Promise<Response>;
35
+ export {};
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseNetworkError = void 0;
4
4
  exports.koolbaseFetch = koolbaseFetch;
5
+ exports.setSessionRenewal = setSessionRenewal;
6
+ exports.sessionFetch = sessionFetch;
5
7
  /**
6
8
  * Network failures, made actionable.
7
9
  *
@@ -52,3 +54,61 @@ async function koolbaseFetch(input, init) {
52
54
  throw new KoolbaseNetworkError(input, e);
53
55
  }
54
56
  }
57
+ let renewal = null;
58
+ /** Installed by Koolbase.initialize: renews the session a request was refused with. */
59
+ function setSessionRenewal(fn) {
60
+ renewal = fn;
61
+ }
62
+ function headerOf(headers, name) {
63
+ if (!headers)
64
+ return undefined;
65
+ if (typeof headers.get === 'function')
66
+ return headers.get(name) ?? undefined;
67
+ if (Array.isArray(headers))
68
+ return headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1];
69
+ const rec = headers;
70
+ const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
71
+ return key ? rec[key] : undefined;
72
+ }
73
+ function withHeader(headers, name, value) {
74
+ const out = {};
75
+ if (headers && typeof headers.forEach === 'function' && !Array.isArray(headers)) {
76
+ headers.forEach((v, k) => { out[k] = v; });
77
+ }
78
+ else if (Array.isArray(headers)) {
79
+ for (const [k, v] of headers)
80
+ out[k] = v;
81
+ }
82
+ else if (headers) {
83
+ Object.assign(out, headers);
84
+ }
85
+ for (const k of Object.keys(out))
86
+ if (k.toLowerCase() === name.toLowerCase())
87
+ delete out[k];
88
+ out[name] = value;
89
+ return out;
90
+ }
91
+ /**
92
+ * koolbaseFetch for a data request. A request refused as unauthenticated
93
+ * (401) while carrying the signed-in person's token is the session needing
94
+ * renewal, not ending: the access token expired early, the clock is off, or
95
+ * another tab renewed it. The session is renewed once and the request sent
96
+ * again with the new token, so the caller never sees the first refusal.
97
+ * Only when renewal is refused does the 401 reach the caller, and with it the
98
+ * client's onSessionExpired, which ends the session.
99
+ */
100
+ async function sessionFetch(input, init) {
101
+ const res = await koolbaseFetch(input, init);
102
+ if (res.status !== 401 || !renewal)
103
+ return res;
104
+ const auth = headerOf(init?.headers, 'Authorization');
105
+ if (!auth || !auth.startsWith('Bearer '))
106
+ return res;
107
+ // Only a body that can be sent twice. Data requests send JSON text.
108
+ if (init?.body !== undefined && init.body !== null && typeof init.body !== 'string')
109
+ return res;
110
+ const fresh = await renewal(auth.slice('Bearer '.length));
111
+ if (!fresh)
112
+ return res;
113
+ return koolbaseFetch(input, { ...init, headers: withHeader(init?.headers, 'Authorization', `Bearer ${fresh}`) });
114
+ }
@@ -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
  }