@geolonia/geonicdb-sdk 0.11.1 → 0.13.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/README.md CHANGED
@@ -326,6 +326,38 @@ const [a, b, c] = await Promise.all([
326
326
 
327
327
  Cache is enabled by default; pass `cache: false` to opt out.
328
328
 
329
+ ### Persistence across reloads
330
+
331
+ In the browser the cache (validators + bodies) is **persisted to IndexedDB** so
332
+ it survives a page reload. A reloaded SPA can therefore send `If-None-Match` on
333
+ its very first request and get a `304` instead of always cold-starting into a
334
+ full `200` body.
335
+
336
+ ```typescript
337
+ // First load: fetched in full and saved to IndexedDB.
338
+ await db.getEntities({ type: 'AedLocation' });
339
+
340
+ // After a page reload, restore the session — the cache rehydrates with it:
341
+ if (await db.restoreSession()) {
342
+ await db.getEntities({ type: 'AedLocation' });
343
+ // → If-None-Match sent; unchanged data comes back as 304 (near-zero bytes).
344
+ }
345
+ ```
346
+
347
+ - Persistence is tied to `rememberSession`: it is active only for sessions that
348
+ themselves survive a reload (`login({ rememberSession: true })` →
349
+ `restoreSession()`), sharing the exact lifecycle of the persisted DPoP session.
350
+ - Entries are namespaced by tenant **and authenticated principal**, so a
351
+ different subject sharing the same origin/tenant (another tab, another SDK
352
+ instance, a later login) never reads entries written by a previous principal.
353
+ They are dropped on every credential change (`login()`, `setCredentials()`,
354
+ `logout()`) and only read back by the same principal that wrote them, via
355
+ `restoreSession()`.
356
+ - Capacity / LRU follow `cacheMaxEntries`, the same as the in-memory cache.
357
+ - In non-browser runtimes (Node, CLI) without IndexedDB the cache stays
358
+ in-memory only — fully backward compatible.
359
+ - `clearCache()` flushes the persisted store as well as the in-memory cache.
360
+
329
361
  ### Cache lifetime under WebSocket activity
330
362
 
331
363
  WebSocket entity events (`entityCreated` / `entityUpdated` / `entityDeleted`)
@@ -558,6 +590,55 @@ try {
558
590
  | `RateLimitError` | 429 | Rate limited (`retryAfter` property) |
559
591
  | `NetworkError` | — | Fetch failure, DNS error, timeout |
560
592
 
593
+ ## React Hooks (`@geolonia/geonicdb-sdk/react`)
594
+
595
+ Optional React integration, shipped as a separate subpath so the core SDK stays
596
+ framework-agnostic. Requires React 18 or 19 (declared as an **optional peer
597
+ dependency** — core consumers are unaffected).
598
+
599
+ ```tsx
600
+ import GeonicDB from '@geolonia/geonicdb-sdk';
601
+ import { useLdEntities, useTemporalData, GeonicDbMap } from '@geolonia/geonicdb-sdk/react';
602
+
603
+ const db = new GeonicDB({ baseUrl, tenant });
604
+
605
+ function Rooms() {
606
+ const { entities, loading, error, refetch } = useLdEntities(db, { type: 'Room', limit: 100 });
607
+ if (loading) return <p>Loading…</p>;
608
+ if (error) return <p>{error.message}</p>;
609
+ return <GeonicDbMap entities={entities} apiKey="YOUR-GEOLONIA-KEY" />;
610
+ }
611
+ ```
612
+
613
+ | Export | Type | Description |
614
+ |--------|------|-------------|
615
+ | `useLdEntities(client, options?)` | hook | NGSI-LD エンティティ取得。`{ entities, loading, error, refetch }` を返す。`options`: `type` / `limit` / `attrs` / `q` / `scopeQ` |
616
+ | `useTemporalData(client, options)` | hook | Temporal API 取得。`{ data, loading, error, refetch }` を返す。`options.id` 指定時は単一エンティティ、`options.enabled === false` で取得スキップ |
617
+ | `GeonicDbMap` | component | Geolonia 地図にエンティティをマーカー描画。`@geolonia/embed`(optional peer)を動的 import |
618
+
619
+ 両 hook は in-flight リクエストを世代管理しており、`options` 変更やアンマウント時に古いレスポンスが新しい結果を上書きしない。`GeonicDbMap` のポップアップは `entity.id` / `entity.type` を HTML エスケープして描画する。
620
+
621
+ ## NGSIv2 Client (`@geolonia/geonicdb-sdk/ngsi-v2`)
622
+
623
+ NGSIv2 (`/v2/entities`) を叩く軽量クライアント。NGSI-LD を使うコア SDK とは独立した
624
+ サブパスで、NGSIv2 のみを使う利用者向け。
625
+
626
+ ```ts
627
+ import { createNgsiV2Client } from '@geolonia/geonicdb-sdk/ngsi-v2';
628
+
629
+ const client = createNgsiV2Client({ baseUrl: 'https://geonicdb.example.com', apiKey: 'KEY', service: 'mytenant' });
630
+ const rooms = await client.getEntities({ type: 'Room', limit: 20 });
631
+ ```
632
+
633
+ | Method | Description |
634
+ |--------|-------------|
635
+ | `createNgsiV2Client(config)` | `NgsiV2Client` を生成。`config`: `baseUrl`(必須)/ `apiKey` / `service` |
636
+ | `getEntities(options?)` | エンティティ一覧(`type` / `q` / `limit` / `offset` / `attrs` / `options`) |
637
+ | `getEntity(id, options?)` | 単一エンティティ取得 |
638
+ | `createEntity(entity)` | エンティティ作成 |
639
+ | `updateEntityAttributes(id, attrs)` | 属性の部分更新 |
640
+ | `deleteEntity(id)` | エンティティ削除 |
641
+
561
642
  ## License
562
643
 
563
644
  MIT
package/geonicdb.cjs CHANGED
@@ -85,6 +85,9 @@ var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
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 SdkCache = class {
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 SdkCache = class {
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 _AuthManager = class _AuthManager {
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 _AuthManager = class _AuthManager {
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 _AuthManager = class _AuthManager {
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 _AuthManager = class _AuthManager {
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 _AuthManager = class _AuthManager {
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 _AuthManager = class _AuthManager {
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 _AuthManager = class _AuthManager {
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 WebSocketManager = class {
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 GeonicDB = class extends EventEmitter {
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 GeonicDB = class extends EventEmitter {
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
  /**