@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.mjs CHANGED
@@ -147,13 +147,178 @@ var RECONNECT_MAX_ATTEMPTS = 10;
147
147
  var RECONNECT_BASE_MS = 1e3;
148
148
  var RECONNECT_MAX_DELAY_MS = 3e4;
149
149
  var SUB_PROTOCOL = "access_token";
150
+ var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
151
+ var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
152
+
153
+ // src/sdk/errors.ts
154
+ var GeonicDBError = class extends Error {
155
+ constructor(message, statusCode = 0) {
156
+ super(message);
157
+ /** HTTP status code (if applicable) */
158
+ __publicField(this, "statusCode");
159
+ this.name = "GeonicDBError";
160
+ this.statusCode = statusCode;
161
+ }
162
+ };
163
+ var AuthenticationError = class extends GeonicDBError {
164
+ constructor(message = "Authentication failed") {
165
+ super(message, 401);
166
+ this.name = "AuthenticationError";
167
+ }
168
+ };
169
+ var AuthorizationError = class extends GeonicDBError {
170
+ constructor(message = "Access denied") {
171
+ super(message, 403);
172
+ this.name = "AuthorizationError";
173
+ }
174
+ };
175
+ var NotFoundError = class extends GeonicDBError {
176
+ constructor(message = "Not found") {
177
+ super(message, 404);
178
+ this.name = "NotFoundError";
179
+ }
180
+ };
181
+ var ConflictError = class extends GeonicDBError {
182
+ constructor(message = "Conflict") {
183
+ super(message, 409);
184
+ this.name = "ConflictError";
185
+ }
186
+ };
187
+ var ValidationError = class extends GeonicDBError {
188
+ constructor(message = "Validation failed") {
189
+ super(message, 422);
190
+ this.name = "ValidationError";
191
+ }
192
+ };
193
+ var RateLimitError = class extends GeonicDBError {
194
+ constructor(message = "Rate limit exceeded", retryAfter = 1) {
195
+ super(message, 429);
196
+ /** Seconds to wait before retrying (from Retry-After header) */
197
+ __publicField(this, "retryAfter");
198
+ this.name = "RateLimitError";
199
+ this.retryAfter = retryAfter;
200
+ }
201
+ };
202
+ var NetworkError = class extends GeonicDBError {
203
+ constructor(message = "Network error") {
204
+ super(message, 0);
205
+ this.name = "NetworkError";
206
+ }
207
+ };
208
+ function createErrorFromResponse(status, body, fallbackMessage) {
209
+ const message = body.detail || body.description || fallbackMessage;
210
+ switch (status) {
211
+ case 401:
212
+ return new AuthenticationError(message);
213
+ case 403:
214
+ return new AuthorizationError(message);
215
+ case 404:
216
+ return new NotFoundError(message);
217
+ case 409:
218
+ return new ConflictError(message);
219
+ case 422:
220
+ return new ValidationError(message);
221
+ case 429:
222
+ return new RateLimitError(message);
223
+ default:
224
+ return new GeonicDBError(message, status);
225
+ }
226
+ }
227
+
228
+ // src/sdk/cache.ts
229
+ var SdkCache = class {
230
+ constructor(maxEntries = SDK_CACHE_MAX_ENTRIES_DEFAULT) {
231
+ __publicField(this, "_store", /* @__PURE__ */ new Map());
232
+ __publicField(this, "_maxEntries");
233
+ if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
234
+ throw new Error(`SdkCache maxEntries must be a positive integer, got ${String(maxEntries)}`);
235
+ }
236
+ this._maxEntries = maxEntries;
237
+ }
238
+ /** Build a stable cache key. Method is upper-cased so casing differences do not produce duplicate entries. */
239
+ static keyFor(method, path) {
240
+ return `${method.toUpperCase()}:${path}`;
241
+ }
242
+ /** Returns the entry and bumps it to most-recent position, or `undefined`. */
243
+ get(key) {
244
+ const entry = this._store.get(key);
245
+ if (!entry) return void 0;
246
+ this._store.delete(key);
247
+ this._store.set(key, entry);
248
+ return entry;
249
+ }
250
+ /** Insert or replace an entry. Evicts the LRU entry when over capacity. */
251
+ set(key, entry) {
252
+ if (this._store.has(key)) {
253
+ this._store.delete(key);
254
+ }
255
+ this._store.set(key, entry);
256
+ while (this._store.size > this._maxEntries) {
257
+ const oldest = this._store.keys().next().value;
258
+ if (oldest === void 0) break;
259
+ this._store.delete(oldest);
260
+ }
261
+ }
262
+ /** Delete a specific entry. Returns whether anything was removed. */
263
+ delete(key) {
264
+ return this._store.delete(key);
265
+ }
266
+ /**
267
+ * Delete every entry whose key matches a predicate. Returns the array of
268
+ * removed keys so callers can emit `cacheInvalidated` events.
269
+ */
270
+ deleteWhere(predicate) {
271
+ const removed = [];
272
+ for (const [key, entry] of this._store) {
273
+ if (predicate(key, entry)) {
274
+ this._store.delete(key);
275
+ removed.push(key);
276
+ }
277
+ }
278
+ return removed;
279
+ }
280
+ /** Drop everything. */
281
+ clear() {
282
+ this._store.clear();
283
+ }
284
+ /** Current entry count. Useful for tests / metrics. */
285
+ size() {
286
+ return this._store.size;
287
+ }
288
+ };
289
+ var HEADERS_TO_PERSIST = [
290
+ "content-type",
291
+ "etag",
292
+ "last-modified",
293
+ "cache-control",
294
+ "vary",
295
+ "link",
296
+ "ngsild-results-count",
297
+ "fiware-total-count",
298
+ "x-total-count",
299
+ "ngsild-next",
300
+ "fiware-next-token"
301
+ ];
302
+ function snapshotHeaders(headers) {
303
+ const out = {};
304
+ for (const name of HEADERS_TO_PERSIST) {
305
+ const value = headers.get(name);
306
+ if (value !== null) out[name] = value;
307
+ }
308
+ return out;
309
+ }
310
+ function isCacheableMethod(method) {
311
+ const upper = method.toUpperCase();
312
+ return upper === "GET" || upper === "HEAD";
313
+ }
150
314
 
151
315
  // src/sdk/auth.ts
152
316
  var _AuthManager = class _AuthManager {
153
- constructor(baseUrl, apiKey, tenant) {
317
+ constructor(baseUrl, apiKey, tenant, debug = false) {
154
318
  __publicField(this, "_baseUrl");
155
319
  __publicField(this, "_apiKey");
156
320
  __publicField(this, "_tenant");
321
+ __publicField(this, "_debug");
157
322
  __publicField(this, "_token", null);
158
323
  __publicField(this, "_tokenExpiry", 0);
159
324
  __publicField(this, "_tokenType", "Bearer");
@@ -164,9 +329,20 @@ var _AuthManager = class _AuthManager {
164
329
  __publicField(this, "_tokenPromise", null);
165
330
  /** Callback to emit tokenRefresh events. Set by GeonicDB class. */
166
331
  __publicField(this, "onTokenRefresh", null);
332
+ /**
333
+ * SDK-level cache (#991 Phase A). When set, cacheable GET requests are
334
+ * served via ETag/304 with automatic If-None-Match negotiation, and
335
+ * concurrent requests to the same path are deduplicated.
336
+ */
337
+ __publicField(this, "_cache", null);
338
+ /** Emitter for cacheHit / cacheMiss / cacheInvalidated events. */
339
+ __publicField(this, "_emitCacheEvent", null);
340
+ /** In-flight request map keyed by `${METHOD}:${path}` for request deduplication. */
341
+ __publicField(this, "_inFlight", /* @__PURE__ */ new Map());
167
342
  this._baseUrl = baseUrl;
168
343
  this._apiKey = apiKey;
169
344
  this._tenant = tenant;
345
+ this._debug = debug;
170
346
  if (dpopSupported) {
171
347
  this._dpopReady = generateDPoPKeyPair().then((kp) => {
172
348
  this._dpopKeyPair = kp;
@@ -175,8 +351,28 @@ var _AuthManager = class _AuthManager {
175
351
  });
176
352
  }
177
353
  }
354
+ _log(...args) {
355
+ if (this._debug) console.log("[GeonicDB]", ...args);
356
+ }
357
+ /** Wire an SdkCache instance (called by GeonicDB constructor when caching is enabled). */
358
+ setCache(cache) {
359
+ this._cache = cache;
360
+ }
361
+ /** Provide the cache event emitter (forwarded to the GeonicDB EventEmitter). */
362
+ setCacheEventEmitter(emitter) {
363
+ this._emitCacheEvent = emitter;
364
+ }
365
+ /** Expose the cache for invalidation (e.g. WebSocket entity events). */
366
+ getCache() {
367
+ return this._cache;
368
+ }
369
+ /** Emit a cache event if a listener is wired. */
370
+ emitCacheEvent(name, payload) {
371
+ this._emitCacheEvent?.(name, payload);
372
+ }
178
373
  /** Login with email and password (Bearer JWT). */
179
374
  async login(email, password) {
375
+ this._log("login", email);
180
376
  const headers = {
181
377
  "Content-Type": "application/json"
182
378
  };
@@ -188,7 +384,7 @@ var _AuthManager = class _AuthManager {
188
384
  });
189
385
  if (!res.ok) {
190
386
  const e = await res.json().catch(() => ({}));
191
- throw new Error(
387
+ throw new AuthenticationError(
192
388
  e.detail || e.description || "Login failed: " + res.status
193
389
  );
194
390
  }
@@ -197,6 +393,7 @@ var _AuthManager = class _AuthManager {
197
393
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
198
394
  this._tokenType = "Bearer";
199
395
  this._refreshToken = data.refreshToken;
396
+ this._invalidateAuthScopedState();
200
397
  return data;
201
398
  }
202
399
  /**
@@ -211,6 +408,7 @@ var _AuthManager = class _AuthManager {
211
408
  this._tokenExpiry = opts.expiresIn != null ? Date.now() + (opts.expiresIn - 60) * 1e3 : Date.now() + DEFAULT_TOKEN_TTL_SEC * 1e3;
212
409
  this._refreshToken = opts.refreshToken || null;
213
410
  this._tokenPromise = null;
411
+ this._invalidateAuthScopedState();
214
412
  }
215
413
  /** Clear all credentials. */
216
414
  logout() {
@@ -218,6 +416,17 @@ var _AuthManager = class _AuthManager {
218
416
  this._tokenExpiry = 0;
219
417
  this._refreshToken = null;
220
418
  this._tokenPromise = null;
419
+ this._invalidateAuthScopedState();
420
+ }
421
+ /**
422
+ * Drop everything that may have been associated with the previous auth
423
+ * context. Called on `login()` (after token assignment), `setCredentials()`,
424
+ * and `logout()`. Without this, a cached body or in-flight Response from
425
+ * user A could be returned to user B after a credentials swap.
426
+ */
427
+ _invalidateAuthScopedState() {
428
+ this._cache?.clear();
429
+ this._inFlight.clear();
221
430
  }
222
431
  /** Ensure a valid token is available, refreshing or acquiring as needed. */
223
432
  async ensureToken() {
@@ -235,6 +444,7 @@ var _AuthManager = class _AuthManager {
235
444
  return this._tokenPromise;
236
445
  }
237
446
  async _refreshBearerToken() {
447
+ this._log("refreshing Bearer token");
238
448
  try {
239
449
  const res = await fetch(this._baseUrl + "/auth/refresh", {
240
450
  method: "POST",
@@ -245,7 +455,7 @@ var _AuthManager = class _AuthManager {
245
455
  this._refreshToken = null;
246
456
  this._token = null;
247
457
  this._tokenPromise = null;
248
- throw new Error("Token refresh failed: " + res.status);
458
+ throw new AuthenticationError("Token refresh failed: " + res.status);
249
459
  }
250
460
  const data = await res.json();
251
461
  this._token = data.accessToken;
@@ -274,7 +484,7 @@ var _AuthManager = class _AuthManager {
274
484
  body: JSON.stringify({ api_key: this._apiKey })
275
485
  });
276
486
  if (!nonceRes.ok)
277
- throw new Error("Nonce request failed: " + nonceRes.status);
487
+ throw new AuthenticationError("Nonce request failed: " + nonceRes.status);
278
488
  const nonceData = await nonceRes.json();
279
489
  if (nonceData.dpop_nonce) this._dpopNonce = nonceData.dpop_nonce;
280
490
  const proof = await solvePoW(nonceData.challenge, nonceData.difficulty);
@@ -287,7 +497,7 @@ var _AuthManager = class _AuthManager {
287
497
  });
288
498
  const res = await this._doTokenExchange(tokenUrl, tokenBody, this._dpopNonce);
289
499
  if (!res.ok)
290
- throw new Error("Token request failed: " + res.status);
500
+ throw new AuthenticationError("Token request failed: " + res.status);
291
501
  const newNonce = res.headers.get("DPoP-Nonce");
292
502
  if (newNonce) this._dpopNonce = newNonce;
293
503
  const data = await res.json();
@@ -327,20 +537,98 @@ var _AuthManager = class _AuthManager {
327
537
  return this._doTokenExchange(tokenUrl, tokenBody, serverNonce, retryCount + 1);
328
538
  }
329
539
  }
330
- throw new Error(
331
- "Token request failed: " + (errBody.error_description || errBody.error)
540
+ throw new GeonicDBError(
541
+ "Token request failed: " + (errBody.error_description || errBody.error),
542
+ 400
332
543
  );
333
544
  }
334
545
  return res;
335
546
  }
336
547
  /**
337
548
  * Make an authenticated HTTP request with automatic token refresh and DPoP.
549
+ *
550
+ * When the SDK cache is enabled and the request is cacheable
551
+ * (GET / HEAD without a body), this method:
552
+ * - Sends `If-None-Match` / `If-Modified-Since` derived from a previously
553
+ * cached entry, if any.
554
+ * - Returns the cached body wrapped in a synthesized `200` Response when
555
+ * the server replies `304 Not Modified`.
556
+ * - Persists fresh `200` responses (with their ETag/Last-Modified) into
557
+ * the cache.
558
+ * - Deduplicates concurrent in-flight requests to the same path.
338
559
  */
339
560
  async request(method, path, body) {
561
+ this._log(method, path);
562
+ if (!this._cache || !isCacheableMethod(method) || body !== void 0) {
563
+ const token = await this.ensureToken();
564
+ const res = await this._doAuthenticatedRequest(method, path, body, token);
565
+ this._log(method, path, "\u2192", res.status);
566
+ return res;
567
+ }
568
+ const key = SdkCache.keyFor(method, path);
569
+ const inFlight = this._inFlight.get(key);
570
+ if (inFlight) {
571
+ return inFlight.then((res) => res.clone());
572
+ }
573
+ const promise = this._cachedRequest(method, path, key);
574
+ this._inFlight.set(key, promise);
575
+ try {
576
+ return await promise;
577
+ } finally {
578
+ this._inFlight.delete(key);
579
+ }
580
+ }
581
+ async _cachedRequest(method, path, key) {
582
+ const cache = this._cache;
583
+ if (!cache) {
584
+ const token2 = await this.ensureToken();
585
+ return this._doAuthenticatedRequest(method, path, void 0, token2);
586
+ }
587
+ const cached = cache.get(key);
588
+ const conditional = {};
589
+ if (cached?.etag) conditional["If-None-Match"] = cached.etag;
590
+ if (cached?.lastModified) conditional["If-Modified-Since"] = cached.lastModified;
340
591
  const token = await this.ensureToken();
341
- return this._doAuthenticatedRequest(method, path, body, token);
592
+ const res = await this._doAuthenticatedRequest(method, path, void 0, token, 0, conditional);
593
+ this._log(method, path, "\u2192", res.status);
594
+ if (res.status === 304 && cached) {
595
+ const refreshedHeaders = { ...cached.headers, ...snapshotHeaders(res.headers) };
596
+ const refreshedEtag = res.headers.get("etag") ?? cached.etag;
597
+ const refreshedLastModified = res.headers.get("last-modified") ?? cached.lastModified;
598
+ cache.set(key, {
599
+ ...cached,
600
+ etag: refreshedEtag,
601
+ lastModified: refreshedLastModified,
602
+ headers: refreshedHeaders,
603
+ cachedAt: Date.now()
604
+ });
605
+ this.emitCacheEvent("cacheHit", { key, path });
606
+ const body = typeof cached.data === "string" ? cached.data : JSON.stringify(cached.data);
607
+ return new Response(body, { status: 200, headers: refreshedHeaders });
608
+ }
609
+ if (res.status === 200) {
610
+ const etag = res.headers.get("etag") ?? void 0;
611
+ const lastModified = res.headers.get("last-modified") ?? void 0;
612
+ if (etag || lastModified) {
613
+ const text = await res.clone().text();
614
+ let data = text;
615
+ try {
616
+ data = JSON.parse(text);
617
+ } catch {
618
+ }
619
+ cache.set(key, {
620
+ etag,
621
+ lastModified,
622
+ data,
623
+ headers: snapshotHeaders(res.headers),
624
+ cachedAt: Date.now()
625
+ });
626
+ this.emitCacheEvent("cacheMiss", { key, path });
627
+ }
628
+ }
629
+ return res;
342
630
  }
343
- async _doAuthenticatedRequest(method, path, body, token, retryCount = 0) {
631
+ async _doAuthenticatedRequest(method, path, body, token, retryCount = 0, extraHeaders = {}) {
344
632
  const url = this._baseUrl + path;
345
633
  const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
346
634
  const bodyStr = body !== void 0 ? JSON.stringify(body) : void 0;
@@ -356,7 +644,8 @@ var _AuthManager = class _AuthManager {
356
644
  const headers = {
357
645
  Authorization: (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken,
358
646
  "Content-Type": "application/ld+json",
359
- Accept: "application/ld+json"
647
+ Accept: "application/ld+json",
648
+ ...extraHeaders
360
649
  };
361
650
  if (dpopProof) headers["DPoP"] = dpopProof;
362
651
  if (this._tenant) headers["Fiware-Service"] = this._tenant;
@@ -392,7 +681,7 @@ var _AuthManager = class _AuthManager {
392
681
  if (retryNonce) this._dpopNonce = retryNonce;
393
682
  const delay = parseInt(res.headers.get("Retry-After") || "1", 10) * 1e3;
394
683
  await new Promise((resolve) => setTimeout(resolve, delay));
395
- return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1);
684
+ return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1, extraHeaders);
396
685
  }
397
686
  return res;
398
687
  }
@@ -423,6 +712,9 @@ var WebSocketManager = class {
423
712
  this._emit = emit;
424
713
  this._wsEndpointOverride = wsEndpointOverride || null;
425
714
  }
715
+ _log(...args) {
716
+ if (this._auth._debug) console.log("[GeonicDB:WS]", ...args);
717
+ }
426
718
  /** Establish WebSocket connection (authentication is automatic). */
427
719
  async connect() {
428
720
  if (this._reconnectTimer) {
@@ -500,10 +792,12 @@ var WebSocketManager = class {
500
792
  const endpoint = await this._discoverWsEndpoint();
501
793
  return new Promise((resolve, reject) => {
502
794
  const wsUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + "tenant=" + encodeURIComponent(this._tenant);
795
+ this._log("connecting", wsUrl);
503
796
  const ws = new WebSocket(wsUrl, [SUB_PROTOCOL, token]);
504
797
  this._ws = ws;
505
798
  ws.onopen = () => {
506
799
  if (this._ws !== ws) return;
800
+ this._log("connected");
507
801
  this._reconnectAttempts = 0;
508
802
  const isDPoP = this._auth._tokenType === "DPoP" && !!this._auth._dpopKeyPair;
509
803
  const bindPromise = isDPoP ? createDPoPProof(this._auth._dpopKeyPair, "GET", wsUrl, null).then(
@@ -548,6 +842,14 @@ var WebSocketManager = class {
548
842
  this._emit("error", new Error(msg.message));
549
843
  return;
550
844
  }
845
+ this._log("event", msg.type, msg.entityId || "");
846
+ if (msg.entityId && msg.data && typeof msg.data === "object") {
847
+ msg.entity = {
848
+ id: msg.entityId,
849
+ type: msg.entityType,
850
+ ...msg.data
851
+ };
852
+ }
551
853
  this._emit(msg.type, msg);
552
854
  this._emit("message", msg);
553
855
  };
@@ -650,19 +952,55 @@ var GeonicDB = class extends EventEmitter {
650
952
  if (!tenant) tenant = script?.getAttribute?.("data-tenant") || "";
651
953
  if (!baseUrl) baseUrl = script?.getAttribute?.("data-base-url") || "";
652
954
  }
653
- this._auth = new AuthManager(baseUrl, apiKey, tenant);
955
+ this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug);
654
956
  this._auth.onTokenRefresh = (creds) => {
655
957
  this.onTokenRefresh?.(creds);
656
958
  this.emit("tokenRefresh", creds);
657
959
  };
960
+ if (opts.cache !== false) {
961
+ const cache = new SdkCache(opts.cacheMaxEntries ?? SDK_CACHE_MAX_ENTRIES_DEFAULT);
962
+ this._auth.setCache(cache);
963
+ this._auth.setCacheEventEmitter((name, payload) => this.emit(name, payload));
964
+ }
658
965
  this._ws = new WebSocketManager(
659
966
  this._auth,
660
967
  baseUrl,
661
968
  tenant,
662
- (event, data) => this.emit(event, data),
969
+ (event, data) => {
970
+ this.emit(event, data);
971
+ if (event === "entityCreated" || event === "entityUpdated" || event === "entityDeleted") {
972
+ this._invalidateCacheForEntityEvent(data);
973
+ }
974
+ },
663
975
  opts.wsEndpoint
664
976
  );
665
977
  }
978
+ /**
979
+ * Drop cache entries that may have been affected by an entity change.
980
+ *
981
+ * The SDK does not know which lists exactly contain the entity, so the
982
+ * conservative strategy is to drop all NGSI-LD / NGSIv2 entity-related
983
+ * paths. ETag re-validation on the next read brings the data back in one
984
+ * round trip with `If-None-Match`, so this remains efficient even when
985
+ * over-invalidating.
986
+ */
987
+ _invalidateCacheForEntityEvent(event) {
988
+ const cache = this._auth.getCache();
989
+ if (!cache) return;
990
+ const entityId = event?.entityId;
991
+ const removed = cache.deleteWhere((key) => {
992
+ if (key.includes("/ngsi-ld/v1/entities") || key.includes("/v2/entities")) return true;
993
+ if (entityId && key.includes(entityId)) return true;
994
+ return false;
995
+ });
996
+ for (const key of removed) {
997
+ this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
998
+ }
999
+ }
1000
+ /** Drop every cached response. Useful in tests and on manual auth changes. */
1001
+ clearCache() {
1002
+ this._auth.getCache()?.clear();
1003
+ }
666
1004
  // --- Authentication ---
667
1005
  /** Login with email and password (Bearer JWT). */
668
1006
  async login(email, password) {
@@ -687,9 +1025,7 @@ var GeonicDB = class extends EventEmitter {
687
1025
  const res = await this._auth.request("POST", "/ngsi-ld/v1/entities", entity);
688
1026
  if (!res.ok) {
689
1027
  const e = await res.json().catch(() => ({}));
690
- throw new Error(
691
- e.detail || e.description || "Create failed"
692
- );
1028
+ throw createErrorFromResponse(res.status, e, "Create failed");
693
1029
  }
694
1030
  return { created: true };
695
1031
  }
@@ -701,35 +1037,88 @@ var GeonicDB = class extends EventEmitter {
701
1037
  );
702
1038
  if (!res.ok) {
703
1039
  const e = await res.json().catch(() => ({}));
704
- throw new Error(
705
- e.detail || e.description || "Not found"
706
- );
1040
+ throw createErrorFromResponse(res.status, e, "Not found");
707
1041
  }
708
1042
  return await res.json();
709
1043
  }
710
1044
  /** Query entities with optional filters. */
711
1045
  async getEntities(params) {
712
- let qs = "";
713
- if (params) {
714
- const parts = [];
715
- if (params.type) parts.push("type=" + encodeURIComponent(params.type));
716
- if (params.limit != null) parts.push("limit=" + params.limit);
717
- if (params.offset != null) parts.push("offset=" + params.offset);
718
- if (params.q) parts.push("q=" + encodeURIComponent(params.q));
719
- if (parts.length) qs = "?" + parts.join("&");
720
- }
721
- const res = await this._auth.request(
722
- "GET",
723
- "/ngsi-ld/v1/entities" + qs
724
- );
1046
+ const res = await this._auth.request("GET", buildEntitiesPath(params));
725
1047
  if (!res.ok) {
726
1048
  const e = await res.json().catch(() => ({}));
727
- throw new Error(
728
- e.detail || e.description || "Query failed"
729
- );
1049
+ throw createErrorFromResponse(res.status, e, "Query failed");
730
1050
  }
731
1051
  return await res.json();
732
1052
  }
1053
+ /**
1054
+ * Poll for entity list changes using ETag-based revalidation (#991 Phase A).
1055
+ *
1056
+ * The handle's `stop()` ends the loop. Each tick performs a normal
1057
+ * `getEntities()` call; the SDK's cache layer issues `If-None-Match`
1058
+ * automatically. The poll detects "no change" by comparing the previous
1059
+ * ETag with the response's ETag (the cache exposes the cached ETag on 304
1060
+ * replays as well, so the comparison stays valid in both 200 and 304
1061
+ * paths).
1062
+ *
1063
+ * @example
1064
+ * ```typescript
1065
+ * const handle = db.poll({ type: 'Room' }, {
1066
+ * interval: 5000,
1067
+ * onData: (rooms) => render(rooms),
1068
+ * onNoChange: () => {},
1069
+ * });
1070
+ * // ...later
1071
+ * handle.stop();
1072
+ * ```
1073
+ */
1074
+ poll(params, options) {
1075
+ const interval = options.interval ?? SDK_POLL_INTERVAL_MS_DEFAULT;
1076
+ if (!Number.isFinite(interval) || interval <= 0) {
1077
+ throw new Error(`poll interval must be a positive number (got ${String(interval)})`);
1078
+ }
1079
+ let stopped = false;
1080
+ let timer = null;
1081
+ let prevEtag;
1082
+ let prevPath;
1083
+ const buildPath = () => buildEntitiesPath(params);
1084
+ const tick = async () => {
1085
+ if (stopped) return;
1086
+ try {
1087
+ const path = prevPath ?? (prevPath = buildPath());
1088
+ const res = await this._auth.request("GET", path);
1089
+ if (stopped) return;
1090
+ const etag = res.headers.get("etag") ?? void 0;
1091
+ if (prevEtag !== void 0 && etag !== void 0 && prevEtag === etag) {
1092
+ options.onNoChange?.();
1093
+ } else {
1094
+ if (!res.ok) {
1095
+ throw createErrorFromResponse(res.status, {}, "Poll request failed");
1096
+ }
1097
+ const data = await res.json();
1098
+ prevEtag = etag;
1099
+ options.onData?.(data);
1100
+ }
1101
+ } catch (err) {
1102
+ options.onError?.(err);
1103
+ } finally {
1104
+ if (!stopped) {
1105
+ timer = setTimeout(() => {
1106
+ void tick();
1107
+ }, interval);
1108
+ }
1109
+ }
1110
+ };
1111
+ void tick();
1112
+ return {
1113
+ stop() {
1114
+ stopped = true;
1115
+ if (timer !== null) {
1116
+ clearTimeout(timer);
1117
+ timer = null;
1118
+ }
1119
+ }
1120
+ };
1121
+ }
733
1122
  /** Count entities matching the given filters. */
734
1123
  async count(params) {
735
1124
  const parts = ["count=true", "limit=0"];
@@ -742,9 +1131,7 @@ var GeonicDB = class extends EventEmitter {
742
1131
  );
743
1132
  if (!res.ok) {
744
1133
  const e = await res.json().catch(() => ({}));
745
- throw new Error(
746
- e.detail || e.description || "Count failed"
747
- );
1134
+ throw createErrorFromResponse(res.status, e, "Count failed");
748
1135
  }
749
1136
  const countHeader = res.headers.get("NGSILD-Results-Count");
750
1137
  return countHeader ? parseInt(countHeader, 10) : 0;
@@ -758,9 +1145,7 @@ var GeonicDB = class extends EventEmitter {
758
1145
  );
759
1146
  if (!res.ok) {
760
1147
  const e = await res.json().catch(() => ({}));
761
- throw new Error(
762
- e.detail || e.description || "Update failed"
763
- );
1148
+ throw createErrorFromResponse(res.status, e, "Update failed");
764
1149
  }
765
1150
  return { updated: true };
766
1151
  }
@@ -772,9 +1157,7 @@ var GeonicDB = class extends EventEmitter {
772
1157
  );
773
1158
  if (!res.ok) {
774
1159
  const e = await res.json().catch(() => ({}));
775
- throw new Error(
776
- e.detail || e.description || "Delete failed"
777
- );
1160
+ throw createErrorFromResponse(res.status, e, "Delete failed");
778
1161
  }
779
1162
  return { deleted: true };
780
1163
  }
@@ -871,9 +1254,7 @@ var GeonicDB = class extends EventEmitter {
871
1254
  const res = await this._auth.request("GET", path);
872
1255
  if (!res.ok) {
873
1256
  const e = await res.json().catch(() => ({}));
874
- throw new Error(
875
- e.detail || e.description || fallbackError
876
- );
1257
+ throw createErrorFromResponse(res.status, e, fallbackError);
877
1258
  }
878
1259
  return await res.json();
879
1260
  }
@@ -882,9 +1263,7 @@ var GeonicDB = class extends EventEmitter {
882
1263
  const res = await this._auth.request("POST", path, body);
883
1264
  if (!res.ok) {
884
1265
  const e = await res.json().catch(() => ({}));
885
- throw new Error(
886
- e.detail || e.description || fallbackError
887
- );
1266
+ throw createErrorFromResponse(res.status, e, fallbackError);
888
1267
  }
889
1268
  if (res.status === 204) return {};
890
1269
  return await res.json();
@@ -898,9 +1277,7 @@ var GeonicDB = class extends EventEmitter {
898
1277
  const res = await this._auth.request(method, path, body);
899
1278
  if (!res.ok) {
900
1279
  const e = await res.json().catch(() => ({}));
901
- throw new Error(
902
- e.detail || e.description || "Request failed: " + res.status
903
- );
1280
+ throw createErrorFromResponse(res.status, e, "Request failed: " + res.status);
904
1281
  }
905
1282
  const ct = res.headers.get("Content-Type") || "";
906
1283
  if (res.status === 204 || !ct) return null;
@@ -940,8 +1317,25 @@ var index_default = GeonicDB;
940
1317
  if (typeof window !== "undefined") {
941
1318
  window.GeonicDB = GeonicDB;
942
1319
  }
1320
+ function buildEntitiesPath(params) {
1321
+ if (!params) return "/ngsi-ld/v1/entities";
1322
+ const parts = [];
1323
+ if (params.type) parts.push("type=" + encodeURIComponent(params.type));
1324
+ if (params.limit != null) parts.push("limit=" + params.limit);
1325
+ if (params.offset != null) parts.push("offset=" + params.offset);
1326
+ if (params.q) parts.push("q=" + encodeURIComponent(params.q));
1327
+ return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
1328
+ }
943
1329
  export {
1330
+ AuthenticationError,
1331
+ AuthorizationError,
1332
+ ConflictError,
944
1333
  GeonicDB,
1334
+ GeonicDBError,
1335
+ NetworkError,
1336
+ NotFoundError,
1337
+ RateLimitError,
1338
+ ValidationError,
945
1339
  index_default as default
946
1340
  };
947
1341
  //# sourceMappingURL=geonicdb.mjs.map