@geolonia/geonicdb-sdk 0.3.0 → 0.5.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 +96 -0
- package/geonicdb.cjs +310 -18
- package/geonicdb.cjs.map +4 -4
- package/geonicdb.iife.js +310 -18
- package/geonicdb.iife.js.map +4 -4
- package/geonicdb.mjs +310 -18
- package/geonicdb.mjs.map +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -176,6 +176,71 @@ await db.connect();
|
|
|
176
176
|
| `reconnecting` | `{ attempt, delay }` | Auto-reconnect starting |
|
|
177
177
|
| `error` | `Error` | Error occurred |
|
|
178
178
|
| `tokenRefresh` | `RefreshedCredentials` | Bearer token was refreshed |
|
|
179
|
+
| `cacheHit` | `CacheEvent` | Request served from in-memory cache (304 received) |
|
|
180
|
+
| `cacheMiss` | `CacheEvent` | Cache miss — fresh response fetched from origin |
|
|
181
|
+
| `cacheInvalidated` | `CacheEvent` | Cache entry dropped via explicit `clearCache()`. WebSocket entity events do **not** trigger cache invalidation — see "Client-side Cache & Polling" |
|
|
182
|
+
|
|
183
|
+
## Client-side Cache & Polling
|
|
184
|
+
|
|
185
|
+
The SDK includes an in-memory cache that handles `ETag` / `If-None-Match`
|
|
186
|
+
negotiation transparently. Subsequent reads of an unchanged resource are
|
|
187
|
+
served as `304 Not Modified` — the SDK presents them to your code as a normal
|
|
188
|
+
`200` response with the cached body.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// First call: fresh fetch, ETag stored
|
|
192
|
+
const rooms1 = await db.getEntities({ type: 'Room' });
|
|
193
|
+
|
|
194
|
+
// Second call: server returns 304, SDK returns the cached body
|
|
195
|
+
const rooms2 = await db.getEntities({ type: 'Room' });
|
|
196
|
+
|
|
197
|
+
// Concurrent calls to the same path are deduplicated:
|
|
198
|
+
const [a, b, c] = await Promise.all([
|
|
199
|
+
db.getEntities({ type: 'Room' }),
|
|
200
|
+
db.getEntities({ type: 'Room' }),
|
|
201
|
+
db.getEntities({ type: 'Room' }),
|
|
202
|
+
]); // → only one HTTP request hits the network
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Cache is enabled by default; pass `cache: false` to opt out.
|
|
206
|
+
|
|
207
|
+
### Cache lifetime under WebSocket activity
|
|
208
|
+
|
|
209
|
+
WebSocket entity events (`entityCreated` / `entityUpdated` / `entityDeleted`)
|
|
210
|
+
do **not** automatically invalidate the SDK cache. This is intentional:
|
|
211
|
+
|
|
212
|
+
- Data endpoints respond with `Cache-Control: private, no-cache`, so every
|
|
213
|
+
cached read already revalidates with the origin via `If-None-Match`.
|
|
214
|
+
- If the entity actually changed, the server's freshly computed `ETag` will
|
|
215
|
+
not match the client's `If-None-Match` → server returns `200` with the
|
|
216
|
+
fresh body → SDK replaces the cache entry.
|
|
217
|
+
- If the WebSocket event was noisy / unrelated to a particular cached query
|
|
218
|
+
(or the data hasn't propagated to the read replica yet), the server returns
|
|
219
|
+
`304 Not Modified` → SDK serves the cached body with no body bytes
|
|
220
|
+
transferred.
|
|
221
|
+
|
|
222
|
+
Deleting cache entries on every WebSocket event would force the next read to
|
|
223
|
+
miss the cache and fetch a full `200` body — defeating the bandwidth-saving
|
|
224
|
+
`304` path. To force a full reset (e.g. on auth change), call `clearCache()`
|
|
225
|
+
explicitly.
|
|
226
|
+
|
|
227
|
+
### Polling
|
|
228
|
+
|
|
229
|
+
`db.poll(params, options)` repeats `getEntities()` at an interval and reports
|
|
230
|
+
back whether the data actually changed. Internally it leverages the cache's
|
|
231
|
+
ETag negotiation, so unchanged ticks transfer almost no bytes.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
const handle = db.poll({ type: 'Room' }, {
|
|
235
|
+
interval: 5000,
|
|
236
|
+
onData: (rooms) => render(rooms),
|
|
237
|
+
onNoChange: () => {}, // server returned 304
|
|
238
|
+
onError: (err) => console.error(err),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Stop later
|
|
242
|
+
handle.stop();
|
|
243
|
+
```
|
|
179
244
|
|
|
180
245
|
## API Reference
|
|
181
246
|
|
|
@@ -194,6 +259,8 @@ await db.connect();
|
|
|
194
259
|
| `baseUrl` | `string` | No | API base URL (auto-detected from script `src` if omitted) |
|
|
195
260
|
| `wsEndpoint` | `string` | No | WebSocket endpoint URL (auto-detected from `baseUrl` if omitted) |
|
|
196
261
|
| `debug` | `boolean` | No | Enable debug logging to console (default: `false`) |
|
|
262
|
+
| `cache` | `boolean` | No | Enable in-memory cache with ETag/304 + request dedup (default: `true`) |
|
|
263
|
+
| `cacheMaxEntries` | `number` | No | LRU cache capacity when `cache` is enabled (default: `1000`) |
|
|
197
264
|
|
|
198
265
|
### Authentication
|
|
199
266
|
|
|
@@ -278,6 +345,35 @@ await db.connect();
|
|
|
278
345
|
| `request(method, path, body?)` | `string, string, unknown` | `Promise<unknown>` | Authenticated request with automatic JSON parsing |
|
|
279
346
|
| `requestRaw(method, path, body?)` | `string, string, unknown` | `Promise<Response>` | Authenticated request returning raw `Response` (for accessing headers) |
|
|
280
347
|
|
|
348
|
+
### Cache & Polling
|
|
349
|
+
|
|
350
|
+
| Method | Parameters | Returns | Description |
|
|
351
|
+
|--------|-----------|---------|-------------|
|
|
352
|
+
| `poll(params, options)` | `GetEntitiesParams \| undefined, PollOptions<T>` | `PollHandle` | ETag-based polling — fires `onData` only when the data actually changes. `options` is **required** (`onData` must be specified to receive data) |
|
|
353
|
+
| `clearCache()` | — | `void` | Drop all cached responses |
|
|
354
|
+
|
|
355
|
+
#### `PollOptions<T>`
|
|
356
|
+
|
|
357
|
+
| Property | Type | Description |
|
|
358
|
+
|----------|------|-------------|
|
|
359
|
+
| `interval` | `number` | Polling interval in milliseconds (default: `5000`) |
|
|
360
|
+
| `onData` | `(data: T) => void` | Called whenever the server returns fresh data (200) |
|
|
361
|
+
| `onNoChange` | `() => void` | Called when the server returns 304 Not Modified |
|
|
362
|
+
| `onError` | `(err: Error) => void` | Called on any fetch/parse failure |
|
|
363
|
+
|
|
364
|
+
#### `PollHandle`
|
|
365
|
+
|
|
366
|
+
| Method | Parameters | Returns | Description |
|
|
367
|
+
|--------|-----------|---------|-------------|
|
|
368
|
+
| `stop()` | — | `void` | Stop the polling timer |
|
|
369
|
+
|
|
370
|
+
#### `CacheEvent`
|
|
371
|
+
|
|
372
|
+
| Property | Type | Description |
|
|
373
|
+
|----------|------|-------------|
|
|
374
|
+
| `key` | `string` | Cache key (`METHOD:path`) |
|
|
375
|
+
| `path` | `string` | Request path |
|
|
376
|
+
|
|
281
377
|
### WebSocket
|
|
282
378
|
|
|
283
379
|
| Method | Parameters | Returns | Description |
|
package/geonicdb.cjs
CHANGED
|
@@ -180,6 +180,8 @@ var RECONNECT_MAX_ATTEMPTS = 10;
|
|
|
180
180
|
var RECONNECT_BASE_MS = 1e3;
|
|
181
181
|
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
182
182
|
var SUB_PROTOCOL = "access_token";
|
|
183
|
+
var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
|
|
184
|
+
var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
|
|
183
185
|
|
|
184
186
|
// src/sdk/errors.ts
|
|
185
187
|
var GeonicDBError = class extends Error {
|
|
@@ -256,6 +258,93 @@ function createErrorFromResponse(status, body, fallbackMessage) {
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
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
|
+
}
|
|
347
|
+
|
|
259
348
|
// src/sdk/auth.ts
|
|
260
349
|
var _AuthManager = class _AuthManager {
|
|
261
350
|
constructor(baseUrl, apiKey, tenant, debug = false) {
|
|
@@ -273,6 +362,16 @@ var _AuthManager = class _AuthManager {
|
|
|
273
362
|
__publicField(this, "_tokenPromise", null);
|
|
274
363
|
/** Callback to emit tokenRefresh events. Set by GeonicDB class. */
|
|
275
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());
|
|
276
375
|
this._baseUrl = baseUrl;
|
|
277
376
|
this._apiKey = apiKey;
|
|
278
377
|
this._tenant = tenant;
|
|
@@ -288,6 +387,22 @@ var _AuthManager = class _AuthManager {
|
|
|
288
387
|
_log(...args) {
|
|
289
388
|
if (this._debug) console.log("[GeonicDB]", ...args);
|
|
290
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
|
+
}
|
|
291
406
|
/** Login with email and password (Bearer JWT). */
|
|
292
407
|
async login(email, password) {
|
|
293
408
|
this._log("login", email);
|
|
@@ -311,6 +426,7 @@ var _AuthManager = class _AuthManager {
|
|
|
311
426
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
312
427
|
this._tokenType = "Bearer";
|
|
313
428
|
this._refreshToken = data.refreshToken;
|
|
429
|
+
this._invalidateAuthScopedState();
|
|
314
430
|
return data;
|
|
315
431
|
}
|
|
316
432
|
/**
|
|
@@ -325,6 +441,7 @@ var _AuthManager = class _AuthManager {
|
|
|
325
441
|
this._tokenExpiry = opts.expiresIn != null ? Date.now() + (opts.expiresIn - 60) * 1e3 : Date.now() + DEFAULT_TOKEN_TTL_SEC * 1e3;
|
|
326
442
|
this._refreshToken = opts.refreshToken || null;
|
|
327
443
|
this._tokenPromise = null;
|
|
444
|
+
this._invalidateAuthScopedState();
|
|
328
445
|
}
|
|
329
446
|
/** Clear all credentials. */
|
|
330
447
|
logout() {
|
|
@@ -332,6 +449,17 @@ var _AuthManager = class _AuthManager {
|
|
|
332
449
|
this._tokenExpiry = 0;
|
|
333
450
|
this._refreshToken = null;
|
|
334
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();
|
|
335
463
|
}
|
|
336
464
|
/** Ensure a valid token is available, refreshing or acquiring as needed. */
|
|
337
465
|
async ensureToken() {
|
|
@@ -451,15 +579,89 @@ var _AuthManager = class _AuthManager {
|
|
|
451
579
|
}
|
|
452
580
|
/**
|
|
453
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.
|
|
454
592
|
*/
|
|
455
593
|
async request(method, path, body) {
|
|
456
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;
|
|
457
624
|
const token = await this.ensureToken();
|
|
458
|
-
const res = await this._doAuthenticatedRequest(method, path,
|
|
625
|
+
const res = await this._doAuthenticatedRequest(method, path, void 0, token, 0, conditional);
|
|
459
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
|
+
}
|
|
460
662
|
return res;
|
|
461
663
|
}
|
|
462
|
-
async _doAuthenticatedRequest(method, path, body, token, retryCount = 0) {
|
|
664
|
+
async _doAuthenticatedRequest(method, path, body, token, retryCount = 0, extraHeaders = {}) {
|
|
463
665
|
const url = this._baseUrl + path;
|
|
464
666
|
const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
|
|
465
667
|
const bodyStr = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
@@ -475,7 +677,8 @@ var _AuthManager = class _AuthManager {
|
|
|
475
677
|
const headers = {
|
|
476
678
|
Authorization: (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken,
|
|
477
679
|
"Content-Type": "application/ld+json",
|
|
478
|
-
Accept: "application/ld+json"
|
|
680
|
+
Accept: "application/ld+json",
|
|
681
|
+
...extraHeaders
|
|
479
682
|
};
|
|
480
683
|
if (dpopProof) headers["DPoP"] = dpopProof;
|
|
481
684
|
if (this._tenant) headers["Fiware-Service"] = this._tenant;
|
|
@@ -511,7 +714,7 @@ var _AuthManager = class _AuthManager {
|
|
|
511
714
|
if (retryNonce) this._dpopNonce = retryNonce;
|
|
512
715
|
const delay = parseInt(res.headers.get("Retry-After") || "1", 10) * 1e3;
|
|
513
716
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
514
|
-
return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1);
|
|
717
|
+
return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1, extraHeaders);
|
|
515
718
|
}
|
|
516
719
|
return res;
|
|
517
720
|
}
|
|
@@ -787,14 +990,37 @@ var GeonicDB = class extends EventEmitter {
|
|
|
787
990
|
this.onTokenRefresh?.(creds);
|
|
788
991
|
this.emit("tokenRefresh", creds);
|
|
789
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
|
+
}
|
|
790
998
|
this._ws = new WebSocketManager(
|
|
791
999
|
this._auth,
|
|
792
1000
|
baseUrl,
|
|
793
1001
|
tenant,
|
|
794
|
-
(event, data) =>
|
|
1002
|
+
(event, data) => {
|
|
1003
|
+
this.emit(event, data);
|
|
1004
|
+
},
|
|
795
1005
|
opts.wsEndpoint
|
|
796
1006
|
);
|
|
797
1007
|
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Drop every cached response. Useful in tests and on manual auth changes.
|
|
1010
|
+
*
|
|
1011
|
+
* Emits a `cacheInvalidated` event for each removed entry so listeners can
|
|
1012
|
+
* track explicit flushes (the WebSocket-driven auto-invalidation was removed
|
|
1013
|
+
* in #1060 to preserve the ETag/304 revalidation path; `clearCache()` is now
|
|
1014
|
+
* the only path that emits this event).
|
|
1015
|
+
*/
|
|
1016
|
+
clearCache() {
|
|
1017
|
+
const cache = this._auth.getCache();
|
|
1018
|
+
if (!cache) return;
|
|
1019
|
+
const removed = cache.deleteWhere(() => true);
|
|
1020
|
+
for (const key of removed) {
|
|
1021
|
+
this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
798
1024
|
// --- Authentication ---
|
|
799
1025
|
/** Login with email and password (Bearer JWT). */
|
|
800
1026
|
async login(email, password) {
|
|
@@ -837,25 +1063,82 @@ var GeonicDB = class extends EventEmitter {
|
|
|
837
1063
|
}
|
|
838
1064
|
/** Query entities with optional filters. */
|
|
839
1065
|
async getEntities(params) {
|
|
840
|
-
|
|
841
|
-
if (params) {
|
|
842
|
-
const parts = [];
|
|
843
|
-
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
844
|
-
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
845
|
-
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
846
|
-
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
847
|
-
if (parts.length) qs = "?" + parts.join("&");
|
|
848
|
-
}
|
|
849
|
-
const res = await this._auth.request(
|
|
850
|
-
"GET",
|
|
851
|
-
"/ngsi-ld/v1/entities" + qs
|
|
852
|
-
);
|
|
1066
|
+
const res = await this._auth.request("GET", buildEntitiesPath(params));
|
|
853
1067
|
if (!res.ok) {
|
|
854
1068
|
const e = await res.json().catch(() => ({}));
|
|
855
1069
|
throw createErrorFromResponse(res.status, e, "Query failed");
|
|
856
1070
|
}
|
|
857
1071
|
return await res.json();
|
|
858
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Poll for entity list changes using ETag-based revalidation (#991 Phase A).
|
|
1075
|
+
*
|
|
1076
|
+
* The handle's `stop()` ends the loop. Each tick performs a normal
|
|
1077
|
+
* `getEntities()` call; the SDK's cache layer issues `If-None-Match`
|
|
1078
|
+
* automatically. The poll detects "no change" by comparing the previous
|
|
1079
|
+
* ETag with the response's ETag (the cache exposes the cached ETag on 304
|
|
1080
|
+
* replays as well, so the comparison stays valid in both 200 and 304
|
|
1081
|
+
* paths).
|
|
1082
|
+
*
|
|
1083
|
+
* @example
|
|
1084
|
+
* ```typescript
|
|
1085
|
+
* const handle = db.poll({ type: 'Room' }, {
|
|
1086
|
+
* interval: 5000,
|
|
1087
|
+
* onData: (rooms) => render(rooms),
|
|
1088
|
+
* onNoChange: () => {},
|
|
1089
|
+
* });
|
|
1090
|
+
* // ...later
|
|
1091
|
+
* handle.stop();
|
|
1092
|
+
* ```
|
|
1093
|
+
*/
|
|
1094
|
+
poll(params, options) {
|
|
1095
|
+
const interval = options.interval ?? SDK_POLL_INTERVAL_MS_DEFAULT;
|
|
1096
|
+
if (!Number.isFinite(interval) || interval <= 0) {
|
|
1097
|
+
throw new Error(`poll interval must be a positive number (got ${String(interval)})`);
|
|
1098
|
+
}
|
|
1099
|
+
let stopped = false;
|
|
1100
|
+
let timer = null;
|
|
1101
|
+
let prevEtag;
|
|
1102
|
+
let prevPath;
|
|
1103
|
+
const buildPath = () => buildEntitiesPath(params);
|
|
1104
|
+
const tick = async () => {
|
|
1105
|
+
if (stopped) return;
|
|
1106
|
+
try {
|
|
1107
|
+
const path = prevPath ?? (prevPath = buildPath());
|
|
1108
|
+
const res = await this._auth.request("GET", path);
|
|
1109
|
+
if (stopped) return;
|
|
1110
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
1111
|
+
if (prevEtag !== void 0 && etag !== void 0 && prevEtag === etag) {
|
|
1112
|
+
options.onNoChange?.();
|
|
1113
|
+
} else {
|
|
1114
|
+
if (!res.ok) {
|
|
1115
|
+
throw createErrorFromResponse(res.status, {}, "Poll request failed");
|
|
1116
|
+
}
|
|
1117
|
+
const data = await res.json();
|
|
1118
|
+
prevEtag = etag;
|
|
1119
|
+
options.onData?.(data);
|
|
1120
|
+
}
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
options.onError?.(err);
|
|
1123
|
+
} finally {
|
|
1124
|
+
if (!stopped) {
|
|
1125
|
+
timer = setTimeout(() => {
|
|
1126
|
+
void tick();
|
|
1127
|
+
}, interval);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
void tick();
|
|
1132
|
+
return {
|
|
1133
|
+
stop() {
|
|
1134
|
+
stopped = true;
|
|
1135
|
+
if (timer !== null) {
|
|
1136
|
+
clearTimeout(timer);
|
|
1137
|
+
timer = null;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
859
1142
|
/** Count entities matching the given filters. */
|
|
860
1143
|
async count(params) {
|
|
861
1144
|
const parts = ["count=true", "limit=0"];
|
|
@@ -1054,4 +1337,13 @@ var index_default = GeonicDB;
|
|
|
1054
1337
|
if (typeof window !== "undefined") {
|
|
1055
1338
|
window.GeonicDB = GeonicDB;
|
|
1056
1339
|
}
|
|
1340
|
+
function buildEntitiesPath(params) {
|
|
1341
|
+
if (!params) return "/ngsi-ld/v1/entities";
|
|
1342
|
+
const parts = [];
|
|
1343
|
+
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
1344
|
+
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
1345
|
+
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
1346
|
+
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
1347
|
+
return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
|
|
1348
|
+
}
|
|
1057
1349
|
//# sourceMappingURL=geonicdb.cjs.map
|