@geolonia/geonicdb-sdk 0.2.1 → 0.4.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.cjs CHANGED
@@ -22,7 +22,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
22
22
  // src/sdk/index.ts
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
+ AuthenticationError: () => AuthenticationError,
26
+ AuthorizationError: () => AuthorizationError,
27
+ ConflictError: () => ConflictError,
25
28
  GeonicDB: () => GeonicDB,
29
+ GeonicDBError: () => GeonicDBError,
30
+ NetworkError: () => NetworkError,
31
+ NotFoundError: () => NotFoundError,
32
+ RateLimitError: () => RateLimitError,
33
+ ValidationError: () => ValidationError,
26
34
  default: () => index_default
27
35
  });
28
36
  module.exports = __toCommonJS(index_exports);
@@ -172,13 +180,178 @@ var RECONNECT_MAX_ATTEMPTS = 10;
172
180
  var RECONNECT_BASE_MS = 1e3;
173
181
  var RECONNECT_MAX_DELAY_MS = 3e4;
174
182
  var SUB_PROTOCOL = "access_token";
183
+ var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
184
+ var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
185
+
186
+ // src/sdk/errors.ts
187
+ var GeonicDBError = class extends Error {
188
+ constructor(message, statusCode = 0) {
189
+ super(message);
190
+ /** HTTP status code (if applicable) */
191
+ __publicField(this, "statusCode");
192
+ this.name = "GeonicDBError";
193
+ this.statusCode = statusCode;
194
+ }
195
+ };
196
+ var AuthenticationError = class extends GeonicDBError {
197
+ constructor(message = "Authentication failed") {
198
+ super(message, 401);
199
+ this.name = "AuthenticationError";
200
+ }
201
+ };
202
+ var AuthorizationError = class extends GeonicDBError {
203
+ constructor(message = "Access denied") {
204
+ super(message, 403);
205
+ this.name = "AuthorizationError";
206
+ }
207
+ };
208
+ var NotFoundError = class extends GeonicDBError {
209
+ constructor(message = "Not found") {
210
+ super(message, 404);
211
+ this.name = "NotFoundError";
212
+ }
213
+ };
214
+ var ConflictError = class extends GeonicDBError {
215
+ constructor(message = "Conflict") {
216
+ super(message, 409);
217
+ this.name = "ConflictError";
218
+ }
219
+ };
220
+ var ValidationError = class extends GeonicDBError {
221
+ constructor(message = "Validation failed") {
222
+ super(message, 422);
223
+ this.name = "ValidationError";
224
+ }
225
+ };
226
+ var RateLimitError = class extends GeonicDBError {
227
+ constructor(message = "Rate limit exceeded", retryAfter = 1) {
228
+ super(message, 429);
229
+ /** Seconds to wait before retrying (from Retry-After header) */
230
+ __publicField(this, "retryAfter");
231
+ this.name = "RateLimitError";
232
+ this.retryAfter = retryAfter;
233
+ }
234
+ };
235
+ var NetworkError = class extends GeonicDBError {
236
+ constructor(message = "Network error") {
237
+ super(message, 0);
238
+ this.name = "NetworkError";
239
+ }
240
+ };
241
+ function createErrorFromResponse(status, body, fallbackMessage) {
242
+ const message = body.detail || body.description || fallbackMessage;
243
+ switch (status) {
244
+ case 401:
245
+ return new AuthenticationError(message);
246
+ case 403:
247
+ return new AuthorizationError(message);
248
+ case 404:
249
+ return new NotFoundError(message);
250
+ case 409:
251
+ return new ConflictError(message);
252
+ case 422:
253
+ return new ValidationError(message);
254
+ case 429:
255
+ return new RateLimitError(message);
256
+ default:
257
+ return new GeonicDBError(message, status);
258
+ }
259
+ }
260
+
261
+ // src/sdk/cache.ts
262
+ var SdkCache = class {
263
+ constructor(maxEntries = SDK_CACHE_MAX_ENTRIES_DEFAULT) {
264
+ __publicField(this, "_store", /* @__PURE__ */ new Map());
265
+ __publicField(this, "_maxEntries");
266
+ if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
267
+ throw new Error(`SdkCache maxEntries must be a positive integer, got ${String(maxEntries)}`);
268
+ }
269
+ this._maxEntries = maxEntries;
270
+ }
271
+ /** Build a stable cache key. Method is upper-cased so casing differences do not produce duplicate entries. */
272
+ static keyFor(method, path) {
273
+ return `${method.toUpperCase()}:${path}`;
274
+ }
275
+ /** Returns the entry and bumps it to most-recent position, or `undefined`. */
276
+ get(key) {
277
+ const entry = this._store.get(key);
278
+ if (!entry) return void 0;
279
+ this._store.delete(key);
280
+ this._store.set(key, entry);
281
+ return entry;
282
+ }
283
+ /** Insert or replace an entry. Evicts the LRU entry when over capacity. */
284
+ set(key, entry) {
285
+ if (this._store.has(key)) {
286
+ this._store.delete(key);
287
+ }
288
+ this._store.set(key, entry);
289
+ while (this._store.size > this._maxEntries) {
290
+ const oldest = this._store.keys().next().value;
291
+ if (oldest === void 0) break;
292
+ this._store.delete(oldest);
293
+ }
294
+ }
295
+ /** Delete a specific entry. Returns whether anything was removed. */
296
+ delete(key) {
297
+ return this._store.delete(key);
298
+ }
299
+ /**
300
+ * Delete every entry whose key matches a predicate. Returns the array of
301
+ * removed keys so callers can emit `cacheInvalidated` events.
302
+ */
303
+ deleteWhere(predicate) {
304
+ const removed = [];
305
+ for (const [key, entry] of this._store) {
306
+ if (predicate(key, entry)) {
307
+ this._store.delete(key);
308
+ removed.push(key);
309
+ }
310
+ }
311
+ return removed;
312
+ }
313
+ /** Drop everything. */
314
+ clear() {
315
+ this._store.clear();
316
+ }
317
+ /** Current entry count. Useful for tests / metrics. */
318
+ size() {
319
+ return this._store.size;
320
+ }
321
+ };
322
+ var HEADERS_TO_PERSIST = [
323
+ "content-type",
324
+ "etag",
325
+ "last-modified",
326
+ "cache-control",
327
+ "vary",
328
+ "link",
329
+ "ngsild-results-count",
330
+ "fiware-total-count",
331
+ "x-total-count",
332
+ "ngsild-next",
333
+ "fiware-next-token"
334
+ ];
335
+ function snapshotHeaders(headers) {
336
+ const out = {};
337
+ for (const name of HEADERS_TO_PERSIST) {
338
+ const value = headers.get(name);
339
+ if (value !== null) out[name] = value;
340
+ }
341
+ return out;
342
+ }
343
+ function isCacheableMethod(method) {
344
+ const upper = method.toUpperCase();
345
+ return upper === "GET" || upper === "HEAD";
346
+ }
175
347
 
176
348
  // src/sdk/auth.ts
177
349
  var _AuthManager = class _AuthManager {
178
- constructor(baseUrl, apiKey, tenant) {
350
+ constructor(baseUrl, apiKey, tenant, debug = false) {
179
351
  __publicField(this, "_baseUrl");
180
352
  __publicField(this, "_apiKey");
181
353
  __publicField(this, "_tenant");
354
+ __publicField(this, "_debug");
182
355
  __publicField(this, "_token", null);
183
356
  __publicField(this, "_tokenExpiry", 0);
184
357
  __publicField(this, "_tokenType", "Bearer");
@@ -189,9 +362,20 @@ var _AuthManager = class _AuthManager {
189
362
  __publicField(this, "_tokenPromise", null);
190
363
  /** Callback to emit tokenRefresh events. Set by GeonicDB class. */
191
364
  __publicField(this, "onTokenRefresh", null);
365
+ /**
366
+ * SDK-level cache (#991 Phase A). When set, cacheable GET requests are
367
+ * served via ETag/304 with automatic If-None-Match negotiation, and
368
+ * concurrent requests to the same path are deduplicated.
369
+ */
370
+ __publicField(this, "_cache", null);
371
+ /** Emitter for cacheHit / cacheMiss / cacheInvalidated events. */
372
+ __publicField(this, "_emitCacheEvent", null);
373
+ /** In-flight request map keyed by `${METHOD}:${path}` for request deduplication. */
374
+ __publicField(this, "_inFlight", /* @__PURE__ */ new Map());
192
375
  this._baseUrl = baseUrl;
193
376
  this._apiKey = apiKey;
194
377
  this._tenant = tenant;
378
+ this._debug = debug;
195
379
  if (dpopSupported) {
196
380
  this._dpopReady = generateDPoPKeyPair().then((kp) => {
197
381
  this._dpopKeyPair = kp;
@@ -200,8 +384,28 @@ var _AuthManager = class _AuthManager {
200
384
  });
201
385
  }
202
386
  }
387
+ _log(...args) {
388
+ if (this._debug) console.log("[GeonicDB]", ...args);
389
+ }
390
+ /** Wire an SdkCache instance (called by GeonicDB constructor when caching is enabled). */
391
+ setCache(cache) {
392
+ this._cache = cache;
393
+ }
394
+ /** Provide the cache event emitter (forwarded to the GeonicDB EventEmitter). */
395
+ setCacheEventEmitter(emitter) {
396
+ this._emitCacheEvent = emitter;
397
+ }
398
+ /** Expose the cache for invalidation (e.g. WebSocket entity events). */
399
+ getCache() {
400
+ return this._cache;
401
+ }
402
+ /** Emit a cache event if a listener is wired. */
403
+ emitCacheEvent(name, payload) {
404
+ this._emitCacheEvent?.(name, payload);
405
+ }
203
406
  /** Login with email and password (Bearer JWT). */
204
407
  async login(email, password) {
408
+ this._log("login", email);
205
409
  const headers = {
206
410
  "Content-Type": "application/json"
207
411
  };
@@ -213,7 +417,7 @@ var _AuthManager = class _AuthManager {
213
417
  });
214
418
  if (!res.ok) {
215
419
  const e = await res.json().catch(() => ({}));
216
- throw new Error(
420
+ throw new AuthenticationError(
217
421
  e.detail || e.description || "Login failed: " + res.status
218
422
  );
219
423
  }
@@ -222,6 +426,7 @@ var _AuthManager = class _AuthManager {
222
426
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
223
427
  this._tokenType = "Bearer";
224
428
  this._refreshToken = data.refreshToken;
429
+ this._invalidateAuthScopedState();
225
430
  return data;
226
431
  }
227
432
  /**
@@ -236,6 +441,7 @@ var _AuthManager = class _AuthManager {
236
441
  this._tokenExpiry = opts.expiresIn != null ? Date.now() + (opts.expiresIn - 60) * 1e3 : Date.now() + DEFAULT_TOKEN_TTL_SEC * 1e3;
237
442
  this._refreshToken = opts.refreshToken || null;
238
443
  this._tokenPromise = null;
444
+ this._invalidateAuthScopedState();
239
445
  }
240
446
  /** Clear all credentials. */
241
447
  logout() {
@@ -243,6 +449,17 @@ var _AuthManager = class _AuthManager {
243
449
  this._tokenExpiry = 0;
244
450
  this._refreshToken = null;
245
451
  this._tokenPromise = null;
452
+ this._invalidateAuthScopedState();
453
+ }
454
+ /**
455
+ * Drop everything that may have been associated with the previous auth
456
+ * context. Called on `login()` (after token assignment), `setCredentials()`,
457
+ * and `logout()`. Without this, a cached body or in-flight Response from
458
+ * user A could be returned to user B after a credentials swap.
459
+ */
460
+ _invalidateAuthScopedState() {
461
+ this._cache?.clear();
462
+ this._inFlight.clear();
246
463
  }
247
464
  /** Ensure a valid token is available, refreshing or acquiring as needed. */
248
465
  async ensureToken() {
@@ -260,6 +477,7 @@ var _AuthManager = class _AuthManager {
260
477
  return this._tokenPromise;
261
478
  }
262
479
  async _refreshBearerToken() {
480
+ this._log("refreshing Bearer token");
263
481
  try {
264
482
  const res = await fetch(this._baseUrl + "/auth/refresh", {
265
483
  method: "POST",
@@ -270,7 +488,7 @@ var _AuthManager = class _AuthManager {
270
488
  this._refreshToken = null;
271
489
  this._token = null;
272
490
  this._tokenPromise = null;
273
- throw new Error("Token refresh failed: " + res.status);
491
+ throw new AuthenticationError("Token refresh failed: " + res.status);
274
492
  }
275
493
  const data = await res.json();
276
494
  this._token = data.accessToken;
@@ -299,7 +517,7 @@ var _AuthManager = class _AuthManager {
299
517
  body: JSON.stringify({ api_key: this._apiKey })
300
518
  });
301
519
  if (!nonceRes.ok)
302
- throw new Error("Nonce request failed: " + nonceRes.status);
520
+ throw new AuthenticationError("Nonce request failed: " + nonceRes.status);
303
521
  const nonceData = await nonceRes.json();
304
522
  if (nonceData.dpop_nonce) this._dpopNonce = nonceData.dpop_nonce;
305
523
  const proof = await solvePoW(nonceData.challenge, nonceData.difficulty);
@@ -312,7 +530,7 @@ var _AuthManager = class _AuthManager {
312
530
  });
313
531
  const res = await this._doTokenExchange(tokenUrl, tokenBody, this._dpopNonce);
314
532
  if (!res.ok)
315
- throw new Error("Token request failed: " + res.status);
533
+ throw new AuthenticationError("Token request failed: " + res.status);
316
534
  const newNonce = res.headers.get("DPoP-Nonce");
317
535
  if (newNonce) this._dpopNonce = newNonce;
318
536
  const data = await res.json();
@@ -352,20 +570,98 @@ var _AuthManager = class _AuthManager {
352
570
  return this._doTokenExchange(tokenUrl, tokenBody, serverNonce, retryCount + 1);
353
571
  }
354
572
  }
355
- throw new Error(
356
- "Token request failed: " + (errBody.error_description || errBody.error)
573
+ throw new GeonicDBError(
574
+ "Token request failed: " + (errBody.error_description || errBody.error),
575
+ 400
357
576
  );
358
577
  }
359
578
  return res;
360
579
  }
361
580
  /**
362
581
  * Make an authenticated HTTP request with automatic token refresh and DPoP.
582
+ *
583
+ * When the SDK cache is enabled and the request is cacheable
584
+ * (GET / HEAD without a body), this method:
585
+ * - Sends `If-None-Match` / `If-Modified-Since` derived from a previously
586
+ * cached entry, if any.
587
+ * - Returns the cached body wrapped in a synthesized `200` Response when
588
+ * the server replies `304 Not Modified`.
589
+ * - Persists fresh `200` responses (with their ETag/Last-Modified) into
590
+ * the cache.
591
+ * - Deduplicates concurrent in-flight requests to the same path.
363
592
  */
364
593
  async request(method, path, body) {
594
+ this._log(method, path);
595
+ if (!this._cache || !isCacheableMethod(method) || body !== void 0) {
596
+ const token = await this.ensureToken();
597
+ const res = await this._doAuthenticatedRequest(method, path, body, token);
598
+ this._log(method, path, "\u2192", res.status);
599
+ return res;
600
+ }
601
+ const key = SdkCache.keyFor(method, path);
602
+ const inFlight = this._inFlight.get(key);
603
+ if (inFlight) {
604
+ return inFlight.then((res) => res.clone());
605
+ }
606
+ const promise = this._cachedRequest(method, path, key);
607
+ this._inFlight.set(key, promise);
608
+ try {
609
+ return await promise;
610
+ } finally {
611
+ this._inFlight.delete(key);
612
+ }
613
+ }
614
+ async _cachedRequest(method, path, key) {
615
+ const cache = this._cache;
616
+ if (!cache) {
617
+ const token2 = await this.ensureToken();
618
+ return this._doAuthenticatedRequest(method, path, void 0, token2);
619
+ }
620
+ const cached = cache.get(key);
621
+ const conditional = {};
622
+ if (cached?.etag) conditional["If-None-Match"] = cached.etag;
623
+ if (cached?.lastModified) conditional["If-Modified-Since"] = cached.lastModified;
365
624
  const token = await this.ensureToken();
366
- return this._doAuthenticatedRequest(method, path, body, token);
625
+ const res = await this._doAuthenticatedRequest(method, path, void 0, token, 0, conditional);
626
+ this._log(method, path, "\u2192", res.status);
627
+ if (res.status === 304 && cached) {
628
+ const refreshedHeaders = { ...cached.headers, ...snapshotHeaders(res.headers) };
629
+ const refreshedEtag = res.headers.get("etag") ?? cached.etag;
630
+ const refreshedLastModified = res.headers.get("last-modified") ?? cached.lastModified;
631
+ cache.set(key, {
632
+ ...cached,
633
+ etag: refreshedEtag,
634
+ lastModified: refreshedLastModified,
635
+ headers: refreshedHeaders,
636
+ cachedAt: Date.now()
637
+ });
638
+ this.emitCacheEvent("cacheHit", { key, path });
639
+ const body = typeof cached.data === "string" ? cached.data : JSON.stringify(cached.data);
640
+ return new Response(body, { status: 200, headers: refreshedHeaders });
641
+ }
642
+ if (res.status === 200) {
643
+ const etag = res.headers.get("etag") ?? void 0;
644
+ const lastModified = res.headers.get("last-modified") ?? void 0;
645
+ if (etag || lastModified) {
646
+ const text = await res.clone().text();
647
+ let data = text;
648
+ try {
649
+ data = JSON.parse(text);
650
+ } catch {
651
+ }
652
+ cache.set(key, {
653
+ etag,
654
+ lastModified,
655
+ data,
656
+ headers: snapshotHeaders(res.headers),
657
+ cachedAt: Date.now()
658
+ });
659
+ this.emitCacheEvent("cacheMiss", { key, path });
660
+ }
661
+ }
662
+ return res;
367
663
  }
368
- async _doAuthenticatedRequest(method, path, body, token, retryCount = 0) {
664
+ async _doAuthenticatedRequest(method, path, body, token, retryCount = 0, extraHeaders = {}) {
369
665
  const url = this._baseUrl + path;
370
666
  const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
371
667
  const bodyStr = body !== void 0 ? JSON.stringify(body) : void 0;
@@ -381,7 +677,8 @@ var _AuthManager = class _AuthManager {
381
677
  const headers = {
382
678
  Authorization: (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken,
383
679
  "Content-Type": "application/ld+json",
384
- Accept: "application/ld+json"
680
+ Accept: "application/ld+json",
681
+ ...extraHeaders
385
682
  };
386
683
  if (dpopProof) headers["DPoP"] = dpopProof;
387
684
  if (this._tenant) headers["Fiware-Service"] = this._tenant;
@@ -417,7 +714,7 @@ var _AuthManager = class _AuthManager {
417
714
  if (retryNonce) this._dpopNonce = retryNonce;
418
715
  const delay = parseInt(res.headers.get("Retry-After") || "1", 10) * 1e3;
419
716
  await new Promise((resolve) => setTimeout(resolve, delay));
420
- return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1);
717
+ return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1, extraHeaders);
421
718
  }
422
719
  return res;
423
720
  }
@@ -448,6 +745,9 @@ var WebSocketManager = class {
448
745
  this._emit = emit;
449
746
  this._wsEndpointOverride = wsEndpointOverride || null;
450
747
  }
748
+ _log(...args) {
749
+ if (this._auth._debug) console.log("[GeonicDB:WS]", ...args);
750
+ }
451
751
  /** Establish WebSocket connection (authentication is automatic). */
452
752
  async connect() {
453
753
  if (this._reconnectTimer) {
@@ -525,10 +825,12 @@ var WebSocketManager = class {
525
825
  const endpoint = await this._discoverWsEndpoint();
526
826
  return new Promise((resolve, reject) => {
527
827
  const wsUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + "tenant=" + encodeURIComponent(this._tenant);
828
+ this._log("connecting", wsUrl);
528
829
  const ws = new WebSocket(wsUrl, [SUB_PROTOCOL, token]);
529
830
  this._ws = ws;
530
831
  ws.onopen = () => {
531
832
  if (this._ws !== ws) return;
833
+ this._log("connected");
532
834
  this._reconnectAttempts = 0;
533
835
  const isDPoP = this._auth._tokenType === "DPoP" && !!this._auth._dpopKeyPair;
534
836
  const bindPromise = isDPoP ? createDPoPProof(this._auth._dpopKeyPair, "GET", wsUrl, null).then(
@@ -573,6 +875,14 @@ var WebSocketManager = class {
573
875
  this._emit("error", new Error(msg.message));
574
876
  return;
575
877
  }
878
+ this._log("event", msg.type, msg.entityId || "");
879
+ if (msg.entityId && msg.data && typeof msg.data === "object") {
880
+ msg.entity = {
881
+ id: msg.entityId,
882
+ type: msg.entityType,
883
+ ...msg.data
884
+ };
885
+ }
576
886
  this._emit(msg.type, msg);
577
887
  this._emit("message", msg);
578
888
  };
@@ -675,19 +985,55 @@ var GeonicDB = class extends EventEmitter {
675
985
  if (!tenant) tenant = script?.getAttribute?.("data-tenant") || "";
676
986
  if (!baseUrl) baseUrl = script?.getAttribute?.("data-base-url") || "";
677
987
  }
678
- this._auth = new AuthManager(baseUrl, apiKey, tenant);
988
+ this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug);
679
989
  this._auth.onTokenRefresh = (creds) => {
680
990
  this.onTokenRefresh?.(creds);
681
991
  this.emit("tokenRefresh", creds);
682
992
  };
993
+ if (opts.cache !== false) {
994
+ const cache = new SdkCache(opts.cacheMaxEntries ?? SDK_CACHE_MAX_ENTRIES_DEFAULT);
995
+ this._auth.setCache(cache);
996
+ this._auth.setCacheEventEmitter((name, payload) => this.emit(name, payload));
997
+ }
683
998
  this._ws = new WebSocketManager(
684
999
  this._auth,
685
1000
  baseUrl,
686
1001
  tenant,
687
- (event, data) => this.emit(event, data),
1002
+ (event, data) => {
1003
+ this.emit(event, data);
1004
+ if (event === "entityCreated" || event === "entityUpdated" || event === "entityDeleted") {
1005
+ this._invalidateCacheForEntityEvent(data);
1006
+ }
1007
+ },
688
1008
  opts.wsEndpoint
689
1009
  );
690
1010
  }
1011
+ /**
1012
+ * Drop cache entries that may have been affected by an entity change.
1013
+ *
1014
+ * The SDK does not know which lists exactly contain the entity, so the
1015
+ * conservative strategy is to drop all NGSI-LD / NGSIv2 entity-related
1016
+ * paths. ETag re-validation on the next read brings the data back in one
1017
+ * round trip with `If-None-Match`, so this remains efficient even when
1018
+ * over-invalidating.
1019
+ */
1020
+ _invalidateCacheForEntityEvent(event) {
1021
+ const cache = this._auth.getCache();
1022
+ if (!cache) return;
1023
+ const entityId = event?.entityId;
1024
+ const removed = cache.deleteWhere((key) => {
1025
+ if (key.includes("/ngsi-ld/v1/entities") || key.includes("/v2/entities")) return true;
1026
+ if (entityId && key.includes(entityId)) return true;
1027
+ return false;
1028
+ });
1029
+ for (const key of removed) {
1030
+ this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
1031
+ }
1032
+ }
1033
+ /** Drop every cached response. Useful in tests and on manual auth changes. */
1034
+ clearCache() {
1035
+ this._auth.getCache()?.clear();
1036
+ }
691
1037
  // --- Authentication ---
692
1038
  /** Login with email and password (Bearer JWT). */
693
1039
  async login(email, password) {
@@ -712,9 +1058,7 @@ var GeonicDB = class extends EventEmitter {
712
1058
  const res = await this._auth.request("POST", "/ngsi-ld/v1/entities", entity);
713
1059
  if (!res.ok) {
714
1060
  const e = await res.json().catch(() => ({}));
715
- throw new Error(
716
- e.detail || e.description || "Create failed"
717
- );
1061
+ throw createErrorFromResponse(res.status, e, "Create failed");
718
1062
  }
719
1063
  return { created: true };
720
1064
  }
@@ -726,35 +1070,88 @@ var GeonicDB = class extends EventEmitter {
726
1070
  );
727
1071
  if (!res.ok) {
728
1072
  const e = await res.json().catch(() => ({}));
729
- throw new Error(
730
- e.detail || e.description || "Not found"
731
- );
1073
+ throw createErrorFromResponse(res.status, e, "Not found");
732
1074
  }
733
1075
  return await res.json();
734
1076
  }
735
1077
  /** Query entities with optional filters. */
736
1078
  async getEntities(params) {
737
- let qs = "";
738
- if (params) {
739
- const parts = [];
740
- if (params.type) parts.push("type=" + encodeURIComponent(params.type));
741
- if (params.limit != null) parts.push("limit=" + params.limit);
742
- if (params.offset != null) parts.push("offset=" + params.offset);
743
- if (params.q) parts.push("q=" + encodeURIComponent(params.q));
744
- if (parts.length) qs = "?" + parts.join("&");
745
- }
746
- const res = await this._auth.request(
747
- "GET",
748
- "/ngsi-ld/v1/entities" + qs
749
- );
1079
+ const res = await this._auth.request("GET", buildEntitiesPath(params));
750
1080
  if (!res.ok) {
751
1081
  const e = await res.json().catch(() => ({}));
752
- throw new Error(
753
- e.detail || e.description || "Query failed"
754
- );
1082
+ throw createErrorFromResponse(res.status, e, "Query failed");
755
1083
  }
756
1084
  return await res.json();
757
1085
  }
1086
+ /**
1087
+ * Poll for entity list changes using ETag-based revalidation (#991 Phase A).
1088
+ *
1089
+ * The handle's `stop()` ends the loop. Each tick performs a normal
1090
+ * `getEntities()` call; the SDK's cache layer issues `If-None-Match`
1091
+ * automatically. The poll detects "no change" by comparing the previous
1092
+ * ETag with the response's ETag (the cache exposes the cached ETag on 304
1093
+ * replays as well, so the comparison stays valid in both 200 and 304
1094
+ * paths).
1095
+ *
1096
+ * @example
1097
+ * ```typescript
1098
+ * const handle = db.poll({ type: 'Room' }, {
1099
+ * interval: 5000,
1100
+ * onData: (rooms) => render(rooms),
1101
+ * onNoChange: () => {},
1102
+ * });
1103
+ * // ...later
1104
+ * handle.stop();
1105
+ * ```
1106
+ */
1107
+ poll(params, options) {
1108
+ const interval = options.interval ?? SDK_POLL_INTERVAL_MS_DEFAULT;
1109
+ if (!Number.isFinite(interval) || interval <= 0) {
1110
+ throw new Error(`poll interval must be a positive number (got ${String(interval)})`);
1111
+ }
1112
+ let stopped = false;
1113
+ let timer = null;
1114
+ let prevEtag;
1115
+ let prevPath;
1116
+ const buildPath = () => buildEntitiesPath(params);
1117
+ const tick = async () => {
1118
+ if (stopped) return;
1119
+ try {
1120
+ const path = prevPath ?? (prevPath = buildPath());
1121
+ const res = await this._auth.request("GET", path);
1122
+ if (stopped) return;
1123
+ const etag = res.headers.get("etag") ?? void 0;
1124
+ if (prevEtag !== void 0 && etag !== void 0 && prevEtag === etag) {
1125
+ options.onNoChange?.();
1126
+ } else {
1127
+ if (!res.ok) {
1128
+ throw createErrorFromResponse(res.status, {}, "Poll request failed");
1129
+ }
1130
+ const data = await res.json();
1131
+ prevEtag = etag;
1132
+ options.onData?.(data);
1133
+ }
1134
+ } catch (err) {
1135
+ options.onError?.(err);
1136
+ } finally {
1137
+ if (!stopped) {
1138
+ timer = setTimeout(() => {
1139
+ void tick();
1140
+ }, interval);
1141
+ }
1142
+ }
1143
+ };
1144
+ void tick();
1145
+ return {
1146
+ stop() {
1147
+ stopped = true;
1148
+ if (timer !== null) {
1149
+ clearTimeout(timer);
1150
+ timer = null;
1151
+ }
1152
+ }
1153
+ };
1154
+ }
758
1155
  /** Count entities matching the given filters. */
759
1156
  async count(params) {
760
1157
  const parts = ["count=true", "limit=0"];
@@ -767,9 +1164,7 @@ var GeonicDB = class extends EventEmitter {
767
1164
  );
768
1165
  if (!res.ok) {
769
1166
  const e = await res.json().catch(() => ({}));
770
- throw new Error(
771
- e.detail || e.description || "Count failed"
772
- );
1167
+ throw createErrorFromResponse(res.status, e, "Count failed");
773
1168
  }
774
1169
  const countHeader = res.headers.get("NGSILD-Results-Count");
775
1170
  return countHeader ? parseInt(countHeader, 10) : 0;
@@ -783,9 +1178,7 @@ var GeonicDB = class extends EventEmitter {
783
1178
  );
784
1179
  if (!res.ok) {
785
1180
  const e = await res.json().catch(() => ({}));
786
- throw new Error(
787
- e.detail || e.description || "Update failed"
788
- );
1181
+ throw createErrorFromResponse(res.status, e, "Update failed");
789
1182
  }
790
1183
  return { updated: true };
791
1184
  }
@@ -797,9 +1190,7 @@ var GeonicDB = class extends EventEmitter {
797
1190
  );
798
1191
  if (!res.ok) {
799
1192
  const e = await res.json().catch(() => ({}));
800
- throw new Error(
801
- e.detail || e.description || "Delete failed"
802
- );
1193
+ throw createErrorFromResponse(res.status, e, "Delete failed");
803
1194
  }
804
1195
  return { deleted: true };
805
1196
  }
@@ -896,9 +1287,7 @@ var GeonicDB = class extends EventEmitter {
896
1287
  const res = await this._auth.request("GET", path);
897
1288
  if (!res.ok) {
898
1289
  const e = await res.json().catch(() => ({}));
899
- throw new Error(
900
- e.detail || e.description || fallbackError
901
- );
1290
+ throw createErrorFromResponse(res.status, e, fallbackError);
902
1291
  }
903
1292
  return await res.json();
904
1293
  }
@@ -907,9 +1296,7 @@ var GeonicDB = class extends EventEmitter {
907
1296
  const res = await this._auth.request("POST", path, body);
908
1297
  if (!res.ok) {
909
1298
  const e = await res.json().catch(() => ({}));
910
- throw new Error(
911
- e.detail || e.description || fallbackError
912
- );
1299
+ throw createErrorFromResponse(res.status, e, fallbackError);
913
1300
  }
914
1301
  if (res.status === 204) return {};
915
1302
  return await res.json();
@@ -923,9 +1310,7 @@ var GeonicDB = class extends EventEmitter {
923
1310
  const res = await this._auth.request(method, path, body);
924
1311
  if (!res.ok) {
925
1312
  const e = await res.json().catch(() => ({}));
926
- throw new Error(
927
- e.detail || e.description || "Request failed: " + res.status
928
- );
1313
+ throw createErrorFromResponse(res.status, e, "Request failed: " + res.status);
929
1314
  }
930
1315
  const ct = res.headers.get("Content-Type") || "";
931
1316
  if (res.status === 204 || !ct) return null;
@@ -965,4 +1350,13 @@ var index_default = GeonicDB;
965
1350
  if (typeof window !== "undefined") {
966
1351
  window.GeonicDB = GeonicDB;
967
1352
  }
1353
+ function buildEntitiesPath(params) {
1354
+ if (!params) return "/ngsi-ld/v1/entities";
1355
+ const parts = [];
1356
+ if (params.type) parts.push("type=" + encodeURIComponent(params.type));
1357
+ if (params.limit != null) parts.push("limit=" + params.limit);
1358
+ if (params.offset != null) parts.push("offset=" + params.offset);
1359
+ if (params.q) parts.push("q=" + encodeURIComponent(params.q));
1360
+ return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
1361
+ }
968
1362
  //# sourceMappingURL=geonicdb.cjs.map