@geolonia/geonicdb-sdk 0.12.0 → 0.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.
package/geonicdb.iife.js CHANGED
@@ -85,6 +85,9 @@ var GeonicDBModule = (() => {
85
85
  var DPOP_IDB_NAME = "geonicdb-sdk";
86
86
  var DPOP_IDB_STORE = "dpop-sessions";
87
87
  var DPOP_IDB_VERSION = 1;
88
+ var SDK_CACHE_IDB_NAME = "geonicdb-sdk-cache";
89
+ var SDK_CACHE_IDB_STORE = "responses";
90
+ var SDK_CACHE_IDB_VERSION = 1;
88
91
 
89
92
  // src/sdk/dpop.ts
90
93
  var dpopSupported = typeof crypto !== "undefined" && !!crypto.subtle && !!crypto.subtle.generateKey;
@@ -364,11 +367,97 @@ var GeonicDBModule = (() => {
364
367
  constructor(maxEntries = SDK_CACHE_MAX_ENTRIES_DEFAULT) {
365
368
  __publicField(this, "_store", /* @__PURE__ */ new Map());
366
369
  __publicField(this, "_maxEntries");
370
+ /**
371
+ * Optional persistent backing store (IndexedDB) so cached validators survive
372
+ * a page reload (#1262). `null` in non-browser runtimes — the cache then
373
+ * behaves as a pure in-memory LRU.
374
+ */
375
+ __publicField(this, "_persistence", null);
376
+ /**
377
+ * Auth-scope namespace for persisted entries. The auth layer sets this to a
378
+ * tenant + authenticated-principal identifier so that entries written by one
379
+ * principal are never read back by another sharing the same origin/tenant
380
+ * (different tabs / SDK instances each scope to their own principal).
381
+ */
382
+ __publicField(this, "_scope", "");
383
+ /**
384
+ * Whether write-through persistence is currently active. Driven by the auth
385
+ * layer: enabled only for remembered sessions, so persisted entries belong to
386
+ * exactly one principal and are dropped on every credential change.
387
+ */
388
+ __publicField(this, "_persistEnabled", false);
389
+ /**
390
+ * Serialises persistence writes so they complete in call order (#1262
391
+ * review). `set`/`delete`/`clearPersisted` are otherwise independent
392
+ * fire-and-forget ops; if a late `put()` resolved after a `remove()` /
393
+ * `clearScope()`, an evicted/deleted/logged-out entry could reappear in
394
+ * IndexedDB. Chaining them keeps the persisted store consistent with the
395
+ * in-memory state and with the auth-boundary flush.
396
+ */
397
+ __publicField(this, "_persistQueue", Promise.resolve());
367
398
  if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
368
399
  throw new Error(`SdkCache maxEntries must be a positive integer, got ${String(maxEntries)}`);
369
400
  }
370
401
  this._maxEntries = maxEntries;
371
402
  }
403
+ /** Wire a persistence backend (called once at construction when available). */
404
+ attachPersistence(persistence) {
405
+ this._persistence = persistence;
406
+ }
407
+ /** Whether a persistence backend is attached. */
408
+ hasPersistence() {
409
+ return this._persistence !== null;
410
+ }
411
+ /** Set the auth-scope namespace used for persisted reads/writes. */
412
+ setScope(scope) {
413
+ this._scope = scope;
414
+ }
415
+ /** Enable or disable write-through persistence (driven by `rememberSession`). */
416
+ setPersistEnabled(enabled) {
417
+ this._persistEnabled = enabled && this._persistence !== null;
418
+ }
419
+ /** True when set/delete should propagate to the persistent store. */
420
+ get _persisting() {
421
+ return this._persistEnabled && this._persistence !== null;
422
+ }
423
+ /**
424
+ * Append a persistence operation to the serialised queue. The backend and
425
+ * scope are captured at enqueue time so a later scope change cannot retarget
426
+ * an already-queued op. A failing op is swallowed (persistence is
427
+ * best-effort) and does not stall the chain. Returns the tail so callers that
428
+ * need ordering (`clearPersisted`) can await it.
429
+ */
430
+ _enqueuePersist(op) {
431
+ const persistence = this._persistence;
432
+ if (!persistence) return this._persistQueue;
433
+ const scope = this._scope;
434
+ this._persistQueue = this._persistQueue.catch(() => void 0).then(() => op(persistence, scope)).catch(() => void 0);
435
+ return this._persistQueue;
436
+ }
437
+ /**
438
+ * Load persisted entries for the current scope into memory, oldest first so
439
+ * the in-memory LRU recency order is preserved. Best-effort and idempotent —
440
+ * a no-op when no backend is attached. Called after a remembered session is
441
+ * restored so the very first request after a reload can send `If-None-Match`.
442
+ */
443
+ async hydrate() {
444
+ if (!this._persistence) return;
445
+ const entries = await this._persistence.loadScope(this._scope, this._maxEntries);
446
+ for (const { key, entry } of entries) {
447
+ if (this._store.has(key)) this._store.delete(key);
448
+ this._store.set(key, entry);
449
+ }
450
+ while (this._store.size > this._maxEntries) {
451
+ const oldest = this._store.keys().next().value;
452
+ if (oldest === void 0) break;
453
+ this._store.delete(oldest);
454
+ }
455
+ }
456
+ /** Drop every persisted entry for the current scope (auth change / flush). */
457
+ async clearPersisted() {
458
+ if (!this._persistence) return;
459
+ await this._enqueuePersist((persistence, scope) => persistence.clearScope(scope));
460
+ }
372
461
  /** Build a stable cache key. Method is upper-cased so casing differences do not produce duplicate entries. */
373
462
  static keyFor(method, path) {
374
463
  return `${method.toUpperCase()}:${path}`;
@@ -387,15 +476,27 @@ var GeonicDBModule = (() => {
387
476
  this._store.delete(key);
388
477
  }
389
478
  this._store.set(key, entry);
479
+ const evicted = [];
390
480
  while (this._store.size > this._maxEntries) {
391
481
  const oldest = this._store.keys().next().value;
392
482
  if (oldest === void 0) break;
393
483
  this._store.delete(oldest);
484
+ evicted.push(oldest);
485
+ }
486
+ if (this._persisting) {
487
+ void this._enqueuePersist(async (persistence, scope) => {
488
+ await persistence.put(scope, key, entry);
489
+ if (evicted.length > 0) await persistence.remove(scope, evicted);
490
+ });
394
491
  }
395
492
  }
396
493
  /** Delete a specific entry. Returns whether anything was removed. */
397
494
  delete(key) {
398
- return this._store.delete(key);
495
+ const removed = this._store.delete(key);
496
+ if (removed && this._persisting) {
497
+ void this._enqueuePersist((persistence, scope) => persistence.remove(scope, [key]));
498
+ }
499
+ return removed;
399
500
  }
400
501
  /**
401
502
  * Delete every entry whose key matches a predicate. Returns the array of
@@ -533,6 +634,49 @@ var GeonicDBModule = (() => {
533
634
  /** Wire an SdkCache instance (called by GeonicDB constructor when caching is enabled). */
534
635
  setCache(cache) {
535
636
  this._cache = cache;
637
+ cache?.setScope(this._cacheScope());
638
+ }
639
+ /**
640
+ * #1262: persistence scope for the SDK cache — tenant + authenticated
641
+ * principal (email when available). Keying on the principal, not just the
642
+ * tenant, prevents a different subject sharing the same origin/tenant (another
643
+ * tab, another SDK instance, a later login) from reading entries written by a
644
+ * previous principal. The principal segment is empty for sessions without a
645
+ * known email; such sessions never persist (persistence is gated on a
646
+ * remembered DPoP session that always carries an email), so the empty segment
647
+ * is not reachable as a write scope in practice.
648
+ */
649
+ _cacheScope() {
650
+ return `${this._tenant}${this._email ?? ""}`;
651
+ }
652
+ /**
653
+ * #1262: enable write-through persistence for the SDK cache so it survives a
654
+ * reload. Always re-scopes to the current tenant + principal first.
655
+ *
656
+ * `clearFirst` drops any persisted entries left by a previous session of the
657
+ * same principal — used on a fresh `login()`. It is `false` on
658
+ * `restoreSession()`, where the persisted entries belong to the very session
659
+ * being restored and are about to be rehydrated.
660
+ */
661
+ async _enablePersistentCache(clearFirst) {
662
+ if (!this._cache) return;
663
+ this._cache.setScope(this._cacheScope());
664
+ if (clearFirst) await this._cache.clearPersisted();
665
+ this._cache.setPersistEnabled(true);
666
+ }
667
+ /**
668
+ * #1262: disable write-through persistence and drop the persisted cache for
669
+ * the current principal. Called on credential changes that do not persist a
670
+ * session (`setCredentials()`, non-remembered `login()`, `logout()`),
671
+ * mirroring the in-memory invalidation so a stale body can never outlive its
672
+ * principal. Callers that null out `_email` must invoke this **before** doing
673
+ * so, otherwise the scope no longer resolves to the principal being dropped.
674
+ */
675
+ _disablePersistentCache() {
676
+ if (!this._cache) return;
677
+ this._cache.setScope(this._cacheScope());
678
+ this._cache.setPersistEnabled(false);
679
+ void this._cache.clearPersisted();
536
680
  }
537
681
  /** Provide the cache event emitter (forwarded to the GeonicDB EventEmitter). */
538
682
  setCacheEventEmitter(emitter) {
@@ -587,6 +731,7 @@ var GeonicDBModule = (() => {
587
731
  this._email = email;
588
732
  this._rememberSession = wantsRemember;
589
733
  this._invalidateAuthScopedState();
734
+ this._cache?.setPersistEnabled(false);
590
735
  if (!wantsRemember && this._tenant) {
591
736
  void clearDPoPSession(this._tenant);
592
737
  }
@@ -596,6 +741,9 @@ var GeonicDBModule = (() => {
596
741
  const bound = await this._bindDpopSession();
597
742
  if (this._rememberSession) {
598
743
  await this._persistDpopSession();
744
+ await this._enablePersistentCache(true);
745
+ } else {
746
+ this._disablePersistentCache();
599
747
  }
600
748
  return bound;
601
749
  } catch (err) {
@@ -604,12 +752,18 @@ var GeonicDBModule = (() => {
604
752
  this._tokenType = "Bearer";
605
753
  this._refreshToken = null;
606
754
  this._tokenPromise = null;
755
+ this._invalidateAuthScopedState();
756
+ this._disablePersistentCache();
607
757
  this._rememberSession = false;
608
758
  this._email = null;
609
- this._invalidateAuthScopedState();
610
759
  throw err;
611
760
  }
612
761
  }
762
+ this._rememberSession = false;
763
+ if (this._tenant) {
764
+ void clearDPoPSession(this._tenant);
765
+ }
766
+ this._disablePersistentCache();
613
767
  return data;
614
768
  }
615
769
  /**
@@ -694,6 +848,12 @@ var GeonicDBModule = (() => {
694
848
  this._refreshToken = opts.refreshToken || null;
695
849
  this._tokenPromise = null;
696
850
  this._invalidateAuthScopedState();
851
+ this._disablePersistentCache();
852
+ this._rememberSession = false;
853
+ this._email = null;
854
+ if (this._tenant) {
855
+ void clearDPoPSession(this._tenant);
856
+ }
697
857
  }
698
858
  /** Clear all credentials. */
699
859
  logout() {
@@ -701,6 +861,7 @@ var GeonicDBModule = (() => {
701
861
  this._tokenExpiry = 0;
702
862
  this._refreshToken = null;
703
863
  this._tokenPromise = null;
864
+ this._disablePersistentCache();
704
865
  this._email = null;
705
866
  if (this._tenant) {
706
867
  void clearDPoPSession(this._tenant);
@@ -746,6 +907,8 @@ var GeonicDBModule = (() => {
746
907
  this._email = persisted.email ?? null;
747
908
  this._tokenPromise = null;
748
909
  this._invalidateAuthScopedState();
910
+ await this._enablePersistentCache(false);
911
+ await this._cache?.hydrate();
749
912
  return true;
750
913
  }
751
914
  /**
@@ -1368,6 +1531,140 @@ var GeonicDBModule = (() => {
1368
1531
  }
1369
1532
  };
1370
1533
 
1534
+ // src/sdk/cache-persistence.ts
1535
+ var cachePersistenceSupported = typeof globalThis.indexedDB !== "undefined" && typeof structuredClone !== "undefined";
1536
+ var SCOPE_SEP = "\0";
1537
+ var SCOPE_RANGE_END = "\uFFFF";
1538
+ function openCacheDB() {
1539
+ return new Promise((resolve, reject) => {
1540
+ const req = globalThis.indexedDB.open(
1541
+ SDK_CACHE_IDB_NAME,
1542
+ SDK_CACHE_IDB_VERSION
1543
+ );
1544
+ req.onupgradeneeded = () => {
1545
+ const db = req.result;
1546
+ if (!db.objectStoreNames.contains(SDK_CACHE_IDB_STORE)) {
1547
+ db.createObjectStore(SDK_CACHE_IDB_STORE, { keyPath: "id" });
1548
+ }
1549
+ };
1550
+ req.onsuccess = () => {
1551
+ resolve(req.result);
1552
+ };
1553
+ req.onerror = () => {
1554
+ reject(req.error ?? new Error("IndexedDB open failed"));
1555
+ };
1556
+ req.onblocked = () => {
1557
+ reject(req.error ?? new Error("IndexedDB open blocked"));
1558
+ };
1559
+ });
1560
+ }
1561
+ function recordId(scope, key) {
1562
+ return scope + SCOPE_SEP + key;
1563
+ }
1564
+ var IdbCachePersistence = class {
1565
+ async loadScope(scope, limit) {
1566
+ let db = null;
1567
+ try {
1568
+ db = await openCacheDB();
1569
+ const records = await new Promise((resolve, reject) => {
1570
+ const tx = db.transaction(SDK_CACHE_IDB_STORE, "readonly");
1571
+ const range = globalThis.IDBKeyRange.bound(
1572
+ scope + SCOPE_SEP,
1573
+ scope + SCOPE_SEP + SCOPE_RANGE_END
1574
+ );
1575
+ const req = tx.objectStore(SDK_CACHE_IDB_STORE).getAll(range);
1576
+ req.onsuccess = () => {
1577
+ resolve(req.result ?? []);
1578
+ };
1579
+ req.onerror = () => {
1580
+ reject(req.error ?? new Error("IndexedDB getAll failed"));
1581
+ };
1582
+ });
1583
+ return records.filter((r) => r && r.scope === scope && typeof r.key === "string" && r.entry).sort((a, b) => (a.entry.cachedAt ?? 0) - (b.entry.cachedAt ?? 0)).slice(-limit).map((r) => ({ key: r.key, entry: r.entry }));
1584
+ } catch {
1585
+ return [];
1586
+ } finally {
1587
+ db?.close();
1588
+ }
1589
+ }
1590
+ async put(scope, key, entry) {
1591
+ let db = null;
1592
+ try {
1593
+ db = await openCacheDB();
1594
+ await new Promise((resolve, reject) => {
1595
+ const tx = db.transaction(SDK_CACHE_IDB_STORE, "readwrite");
1596
+ const record = { id: recordId(scope, key), scope, key, entry };
1597
+ tx.objectStore(SDK_CACHE_IDB_STORE).put(record);
1598
+ tx.oncomplete = () => {
1599
+ resolve();
1600
+ };
1601
+ tx.onerror = () => {
1602
+ reject(tx.error ?? new Error("IndexedDB put failed"));
1603
+ };
1604
+ tx.onabort = () => {
1605
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
1606
+ };
1607
+ });
1608
+ } catch {
1609
+ } finally {
1610
+ db?.close();
1611
+ }
1612
+ }
1613
+ async remove(scope, keys) {
1614
+ if (keys.length === 0) return;
1615
+ let db = null;
1616
+ try {
1617
+ db = await openCacheDB();
1618
+ await new Promise((resolve, reject) => {
1619
+ const tx = db.transaction(SDK_CACHE_IDB_STORE, "readwrite");
1620
+ const store = tx.objectStore(SDK_CACHE_IDB_STORE);
1621
+ for (const key of keys) store.delete(recordId(scope, key));
1622
+ tx.oncomplete = () => {
1623
+ resolve();
1624
+ };
1625
+ tx.onerror = () => {
1626
+ reject(tx.error ?? new Error("IndexedDB delete failed"));
1627
+ };
1628
+ tx.onabort = () => {
1629
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
1630
+ };
1631
+ });
1632
+ } catch {
1633
+ } finally {
1634
+ db?.close();
1635
+ }
1636
+ }
1637
+ async clearScope(scope) {
1638
+ let db = null;
1639
+ try {
1640
+ db = await openCacheDB();
1641
+ await new Promise((resolve, reject) => {
1642
+ const tx = db.transaction(SDK_CACHE_IDB_STORE, "readwrite");
1643
+ const range = globalThis.IDBKeyRange.bound(
1644
+ scope + SCOPE_SEP,
1645
+ scope + SCOPE_SEP + SCOPE_RANGE_END
1646
+ );
1647
+ tx.objectStore(SDK_CACHE_IDB_STORE).delete(range);
1648
+ tx.oncomplete = () => {
1649
+ resolve();
1650
+ };
1651
+ tx.onerror = () => {
1652
+ reject(tx.error ?? new Error("IndexedDB clear failed"));
1653
+ };
1654
+ tx.onabort = () => {
1655
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
1656
+ };
1657
+ });
1658
+ } catch {
1659
+ } finally {
1660
+ db?.close();
1661
+ }
1662
+ }
1663
+ };
1664
+ function createCachePersistence() {
1665
+ return cachePersistenceSupported ? new IdbCachePersistence() : null;
1666
+ }
1667
+
1371
1668
  // src/sdk/index.ts
1372
1669
  var GeonicDB = class extends EventEmitter {
1373
1670
  constructor(options) {
@@ -1397,6 +1694,8 @@ var GeonicDBModule = (() => {
1397
1694
  };
1398
1695
  if (opts.cache !== false) {
1399
1696
  const cache = new SdkCache(opts.cacheMaxEntries ?? SDK_CACHE_MAX_ENTRIES_DEFAULT);
1697
+ const persistence = createCachePersistence();
1698
+ if (persistence) cache.attachPersistence(persistence);
1400
1699
  this._auth.setCache(cache);
1401
1700
  this._auth.setCacheEventEmitter((name, payload) => this.emit(name, payload));
1402
1701
  }
@@ -1425,6 +1724,7 @@ var GeonicDBModule = (() => {
1425
1724
  for (const key of removed) {
1426
1725
  this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
1427
1726
  }
1727
+ void cache.clearPersisted();
1428
1728
  }
1429
1729
  // --- Authentication ---
1430
1730
  /**