@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/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`)
@@ -601,12 +633,39 @@ const rooms = await client.getEntities({ type: 'Room', limit: 20 });
601
633
  | Method | Description |
602
634
  |--------|-------------|
603
635
  | `createNgsiV2Client(config)` | `NgsiV2Client` を生成。`config`: `baseUrl`(必須)/ `apiKey` / `service` |
604
- | `getEntities(options?)` | エンティティ一覧(`type` / `q` / `limit` / `offset` / `attrs` / `options`) |
636
+ | `getEntities(options?)` | エンティティ一覧(`type` / `q` / `limit` / `offset` / `attrs` / `options` / `georel` / `geometry` / `coords`) |
605
637
  | `getEntity(id, options?)` | 単一エンティティ取得 |
606
638
  | `createEntity(entity)` | エンティティ作成 |
607
639
  | `updateEntityAttributes(id, attrs)` | 属性の部分更新 |
608
640
  | `deleteEntity(id)` | エンティティ削除 |
609
641
 
642
+ ### Geospatial query (`georel` / `geometry` / `coords`)
643
+
644
+ NGSIv2 の地理空間クエリに対応。`georel`・`geometry`・`coords` は 3 つ揃えて指定する(部分指定は Orion 側で `400`)。
645
+
646
+ | Option | Type | Description |
647
+ |--------|------|-------------|
648
+ | `georel` | `string` | 地理的関係。複合形も可(例: `near;maxDistance:1000`, `coveredBy`) |
649
+ | `geometry` | `'point' \| 'line' \| 'polygon' \| 'box'` | ジオメトリ種別 |
650
+ | `coords` | `string` | WGS84 座標。複数点はセミコロン区切り(例: `35.6,139.7`) |
651
+
652
+ ```ts
653
+ // 指定座標から半径 1km 以内
654
+ const nearby = await client.getEntities({
655
+ type: 'PublicFacility',
656
+ georel: 'near;maxDistance:1000',
657
+ geometry: 'point',
658
+ coords: '35.6,139.7',
659
+ });
660
+
661
+ // ポリゴン内に含まれるエンティティ
662
+ const inArea = await client.getEntities({
663
+ georel: 'coveredBy',
664
+ geometry: 'polygon',
665
+ coords: '0,0;1,0;1,1;0,1;0,0',
666
+ });
667
+ ```
668
+
610
669
  ## License
611
670
 
612
671
  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
  /**