mikser-io-sdk-api 3.5.1 → 3.7.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 +123 -0
- package/index.d.ts +39 -11
- package/index.js +4 -2
- package/package.json +1 -1
- package/src/asset.js +44 -14
- package/src/cache.js +86 -0
package/README.md
CHANGED
|
@@ -285,6 +285,21 @@ expand: ['sections.*.image']
|
|
|
285
285
|
// → each section's $image becomes the resolved image entity
|
|
286
286
|
```
|
|
287
287
|
|
|
288
|
+
**Resolve every reference with `$`** — when you don't want to enumerate a document's shape:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
// $ resolves every $-keyed ref reachable in the doc, one hop, at any depth.
|
|
292
|
+
expand: ['$']
|
|
293
|
+
// → author, hero, every section's image, every related entry — all hydrated,
|
|
294
|
+
// without naming where any of them sit.
|
|
295
|
+
|
|
296
|
+
// $.$.$ walks the resolved graph deeper: refs, then refs of those, then refs
|
|
297
|
+
// of those. A literal prefix scopes it — 'faq.$' resolves refs under faq only.
|
|
298
|
+
expand: ['$.$.$', 'faq.$']
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Where `*` iterates array indices, `$` iterates *references* — it descends objects and arrays structure-agnostically and expands what it finds. Useful when a consumer wants resolution but shouldn't have to know the document's internal shape (a framework SDK passing `expand: ['$']` for the current document, say). Still bounded by the same caps below — `maxResolved` is what keeps `$` honest.
|
|
302
|
+
|
|
288
303
|
**Multiple paths in one call**:
|
|
289
304
|
|
|
290
305
|
```js
|
|
@@ -489,6 +504,55 @@ Equivalent to:
|
|
|
489
504
|
|
|
490
505
|
`live()` keeps an internal `items` array, patches it on each event, and hands the whole array to `onChange` every time. That's the simplest contract for frameworks with state-replace semantics (the callback just overwrites state). If you need per-event deltas — animated reveals, audit logs, derived counters — use `watch()` directly.
|
|
491
506
|
|
|
507
|
+
### `createCache(docs)` / `cacheKey(query)` — load-once memoized reads
|
|
508
|
+
|
|
509
|
+
A local request cache: dedupe + memoize over an entities client's `list()`. For content you read repeatedly but that changes rarely — system docs, navigation, site settings — re-fetching on every component mount is wasted work. `createCache` is the lightweight tier next to `live()`: `live()` is an always-fresh SSE subscription; this is **load-once with explicit invalidation**.
|
|
510
|
+
|
|
511
|
+
```js
|
|
512
|
+
import { createCache } from 'mikser-io-sdk-api'
|
|
513
|
+
|
|
514
|
+
const cache = createCache(client.entities('public'))
|
|
515
|
+
|
|
516
|
+
// First call fetches; subsequent calls for the same query are served from memory.
|
|
517
|
+
const { items } = await cache.get({ filter: { type: 'navigation' } })
|
|
518
|
+
|
|
519
|
+
// Sync read — envelope if loaded, undefined otherwise.
|
|
520
|
+
cache.peek({ filter: { type: 'navigation' } }) // { items, total, … } | undefined
|
|
521
|
+
|
|
522
|
+
// Drop entries when you know the underlying content changed.
|
|
523
|
+
cache.invalidate({ filter: { type: 'navigation' } }) // one entry
|
|
524
|
+
cache.invalidate() // everything
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
`createCache(client.entities('public'))` returns:
|
|
528
|
+
|
|
529
|
+
| Method | What it does |
|
|
530
|
+
|---|---|
|
|
531
|
+
| `get(query, opts)` | Returns the same envelope as `list()` (`{ items, total, … }`). Memoizes the result; concurrent `get()`s for the same query share one in-flight request. A **failed** `get()` is not memoized — the next call retries. |
|
|
532
|
+
| `peek(query)` | Sync read: the cached envelope, or `undefined` if not loaded. No fetch. |
|
|
533
|
+
| `has(query)` | Sync `true`/`false` — is this query cached? |
|
|
534
|
+
| `invalidate(query?)` | Drop one entry (`invalidate(query)`) or all of them (`invalidate()`). |
|
|
535
|
+
| `subscribe(cb)` | Register a change listener; called on any `get` resolution or `invalidate`. Returns an unsubscribe fn. The framework SDKs build reactive reads on top of this. |
|
|
536
|
+
| `key` | The `cacheKey` function (below), exposed for callers that want to key their own structures the same way. |
|
|
537
|
+
|
|
538
|
+
**Keyed by the whole query.** The cache key covers `filter` / `sort` / `fields` / `expand` / `limit` / `skip` / `page` — so a with-expand and a without-expand read of the **same filter** are *distinct* entries. This is the same identity rule `cacheKeyFor()` and the api plugin's on-disk cache name already follow; a key that ignored `expand` would let a no-expand load shadow an expanded one. `cacheKey(query)` is the pure key function if you need it standalone:
|
|
539
|
+
|
|
540
|
+
```js
|
|
541
|
+
import { cacheKey } from 'mikser-io-sdk-api'
|
|
542
|
+
|
|
543
|
+
cacheKey({ filter: { type: 'nav' } })
|
|
544
|
+
cacheKey({ filter: { type: 'nav' }, expand: ['icon'] }) // ≠ the line above
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
#### Which caching tier?
|
|
548
|
+
|
|
549
|
+
| Tier | Surface | Use when |
|
|
550
|
+
|---|---|---|
|
|
551
|
+
| One-shot | `list(query)` | A single read; you'll re-fetch yourself if you need it again. |
|
|
552
|
+
| Stateful pages | `paginator(options)` | UI navigation through pages; current-page state lives in the paginator. |
|
|
553
|
+
| Always-fresh feed | `live(filter, onChange)` | The view must stay in sync as content changes (SSE-driven). |
|
|
554
|
+
| Load-once memoized | `createCache(docs)` | Read-repeatedly, changes-rarely content; load once, invalidate explicitly. |
|
|
555
|
+
|
|
492
556
|
### `update(payload)` / `delete(payload)` — writes
|
|
493
557
|
|
|
494
558
|
Requires a token-gated endpoint with `operations: ['update', 'delete', ...]`.
|
|
@@ -523,6 +587,65 @@ Return shape follows the response `content-type`:
|
|
|
523
587
|
- `text/*` → `string`
|
|
524
588
|
- anything else (`application/pdf`, images, …) → `ArrayBuffer`
|
|
525
589
|
|
|
590
|
+
## Assets
|
|
591
|
+
|
|
592
|
+
Helpers for resolving served files and their transcoded derivatives. mikser's `assets()` plugin is a preset transcoder (video, image, pdf, audio), not an image pipeline — so these are format-neutral. Image-specific concerns (srcset, dimensions, `<img>` props) are a consumer concern; build them on top of `meta` where you actually know an asset is an image.
|
|
593
|
+
|
|
594
|
+
### `deployedUrl(ref, { baseUrl })` — prefix a served path with the client base
|
|
595
|
+
|
|
596
|
+
The catalog now carries the served path itself — `meta.url` for a file, `meta.presets.<name>` for a transcoded derivative; the engine stamps them per [ADR-0011](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/decisions/0011-asset-urls.md). So the SDK no longer *constructs* `/assets/<preset>/<source>` client-side; it just prefixes the base. One rule for every served reference, files and derivatives alike.
|
|
597
|
+
|
|
598
|
+
```js
|
|
599
|
+
import { deployedUrl } from 'mikser-io-sdk-api'
|
|
600
|
+
|
|
601
|
+
deployedUrl(product.image.meta.url, { baseUrl: 'https://cms.example.com' })
|
|
602
|
+
// → https://cms.example.com/img/products/x.jpg
|
|
603
|
+
|
|
604
|
+
deployedUrl(product.video.meta.presets.poster, { baseUrl: 'https://cms.example.com' })
|
|
605
|
+
// → https://cms.example.com/assets/poster/…x.jpg
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
- **Empty `baseUrl`** (the default) → returns the ref root-relative, for same-origin serving.
|
|
609
|
+
- **Already-absolute ref** (`https://…` — e.g. a render baked the origin in) → passes through untouched.
|
|
610
|
+
- **Falsy ref** → `''`.
|
|
611
|
+
|
|
612
|
+
```js
|
|
613
|
+
deployedUrl('/img/x.jpg') // '/img/x.jpg' (root-relative)
|
|
614
|
+
deployedUrl('/img/x.jpg', { baseUrl: 'https://cms' }) // 'https://cms/img/x.jpg'
|
|
615
|
+
deployedUrl('https://cdn.example.com/x.jpg') // unchanged (absolute passthrough)
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
> `deployedUrl` **replaces** the old `assetUrl(source, preset, { ext })`, which constructed the derivative path client-side. That path now lives in the catalog (`meta.url` / `meta.presets.<name>`), so the SDK only prefixes the base — `assetUrl` is gone.
|
|
619
|
+
|
|
620
|
+
### `watchAssetFallbacks({ doc, warn })` — dev-mode safety net
|
|
621
|
+
|
|
622
|
+
A development aid (ADR-0011 Part E). It warns when an `<img>` or `<video>` fails to load — the signature of a served-file URL that hit the SPA's HTML fallback. When a served URL is missing its base prefix or points at an unexpanded reference, the app origin answers with `text/html`, which can't decode as media, so the element fires an `error` event. `watchAssetFallbacks` catches that and warns, pointing at the likely cause.
|
|
623
|
+
|
|
624
|
+
```js
|
|
625
|
+
import { watchAssetFallbacks } from 'mikser-io-sdk-api'
|
|
626
|
+
|
|
627
|
+
if (import.meta.env.DEV) watchAssetFallbacks()
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
It installs a **capture-phase** `error` listener (media `error` events don't bubble) and returns a teardown function. Outside a browser it's a no-op that returns a no-op teardown, so it's safe to call unconditionally during SSR.
|
|
631
|
+
|
|
632
|
+
| Option | Default | What it does |
|
|
633
|
+
|---|---|---|
|
|
634
|
+
| `doc` | `globalThis.document` | The document to listen on. Override for an iframe or a test DOM. |
|
|
635
|
+
| `warn` | `console.warn` | Where the warning goes. Override to route into your own logger. |
|
|
636
|
+
|
|
637
|
+
### `createAssetIndex(assets)` — id → `{ url, meta }` lookup
|
|
638
|
+
|
|
639
|
+
Format-neutral lookup for managed asset entities that carry their own URL and metadata. `createAssetIndex(assets)` returns `{ asset, map }`; `asset(ref)` resolves an entity `id` to `{ url, meta } | null`, and `map` is the same data as a plain object. `meta` is the entity's raw meta block — opaque (mime, dimensions, duration, whatever the preset emitted), so a consumer that knows an asset is an image reads `meta.width` / `meta.srcset` itself.
|
|
640
|
+
|
|
641
|
+
```js
|
|
642
|
+
import { createAssetIndex } from 'mikser-io-sdk-api'
|
|
643
|
+
|
|
644
|
+
const { asset } = createAssetIndex(items)
|
|
645
|
+
asset('/images/launch-hero') // { url: '/img/launch-hero.jpg', meta: { width: 1600, … } }
|
|
646
|
+
asset('/images/missing') // null
|
|
647
|
+
```
|
|
648
|
+
|
|
526
649
|
## Recipes — composing real-time and search
|
|
527
650
|
|
|
528
651
|
The methods above are the building blocks. The interesting work is gluing them together — `list()` for an initial snapshot, `watch()` to keep it fresh, and `findSimilar()` (from [`mikser-io-sdk-vector`](https://github.com/almero-digital-marketing/mikser-io-sdk-vector)) when the user is searching by meaning rather than fields.
|
package/index.d.ts
CHANGED
|
@@ -311,20 +311,19 @@ export interface AssetIndex {
|
|
|
311
311
|
map: Record<string, AssetRecord>
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
314
|
+
/**
|
|
315
|
+
* Join a base-relative served path to a base. An empty baseUrl yields a
|
|
316
|
+
* root-relative URL; an already-absolute ref passes through unchanged; an
|
|
317
|
+
* empty ref returns ''.
|
|
318
|
+
*/
|
|
319
|
+
export function deployedUrl(ref: string, options?: { baseUrl?: string }): string
|
|
320
320
|
|
|
321
321
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* image pipeline.
|
|
322
|
+
* Dev-mode load-failure warner: attaches listeners that log a warning when
|
|
323
|
+
* an <img>/<video> fails to load. Returns a teardown function. No-op
|
|
324
|
+
* outside a browser.
|
|
326
325
|
*/
|
|
327
|
-
export function
|
|
326
|
+
export function watchAssetFallbacks(options?: { doc?: Document; warn?: (message: string) => void }): () => void
|
|
328
327
|
|
|
329
328
|
/**
|
|
330
329
|
* Build a format-neutral lookup (id → { url, meta }) from a snapshot of
|
|
@@ -335,3 +334,32 @@ export function createAssetIndex(
|
|
|
335
334
|
assets: Array<{ id: string; meta?: Record<string, unknown> }>,
|
|
336
335
|
): AssetIndex
|
|
337
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Memoizing cache over an entities client's list() — keyed by query.
|
|
339
|
+
* Frameworks wrap this in their own reactive shell; the engine layer
|
|
340
|
+
* just deduplicates and serves cached envelopes.
|
|
341
|
+
*/
|
|
342
|
+
export interface Cache {
|
|
343
|
+
/** Fetch (and cache) the envelope for a query; resolves from cache when present. */
|
|
344
|
+
get(query?: ListQuery, options?: object): Promise<ListEnvelope>
|
|
345
|
+
/** Synchronously read a cached envelope without fetching, or undefined when absent. */
|
|
346
|
+
peek(query?: ListQuery): ListEnvelope | undefined
|
|
347
|
+
/** Whether a cached envelope exists for the query. */
|
|
348
|
+
has(query?: ListQuery): boolean
|
|
349
|
+
/** Drop the cached envelope for the query and notify subscribers. */
|
|
350
|
+
invalidate(query?: ListQuery): void
|
|
351
|
+
/** Subscribe to cache changes; returns an unsubscribe function. */
|
|
352
|
+
subscribe(callback: () => void): () => void
|
|
353
|
+
/** The canonical cache key for a query — stable across equivalent queries. */
|
|
354
|
+
key(query?: ListQuery): string
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Build a {@link Cache} over an object exposing `list(query, options)` —
|
|
359
|
+
* typically an EntitiesClient.
|
|
360
|
+
*/
|
|
361
|
+
export function createCache(docs: { list(query?: ListQuery, options?: object): Promise<ListEnvelope> }): Cache
|
|
362
|
+
|
|
363
|
+
/** The canonical cache key for a query — stable across equivalent queries. */
|
|
364
|
+
export function cacheKey(query?: ListQuery): string
|
|
365
|
+
|
package/index.js
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
// src/entities.js — per-endpoint entities client (list / watch / live / ...)
|
|
30
30
|
// src/routes.js — generateMikserRoutes (build-time route enumeration)
|
|
31
31
|
// src/href.js — createHrefIndex (multilingual reference → URL lookup)
|
|
32
|
-
// src/asset.js —
|
|
32
|
+
// src/asset.js — deployedUrl / watchAssetFallbacks / createAssetIndex
|
|
33
|
+
// src/cache.js — createCache / cacheKey (load-once memoized list())
|
|
33
34
|
import { MikserError } from './src/error.js'
|
|
34
35
|
import { createEntitiesClient } from './src/entities.js'
|
|
35
36
|
|
|
@@ -65,4 +66,5 @@ export function createClient({
|
|
|
65
66
|
export { MikserError }
|
|
66
67
|
export { generateMikserRoutes } from './src/routes.js'
|
|
67
68
|
export { createHrefIndex } from './src/href.js'
|
|
68
|
-
export { createAssetIndex,
|
|
69
|
+
export { createAssetIndex, deployedUrl, watchAssetFallbacks } from './src/asset.js'
|
|
70
|
+
export { createCache, cacheKey } from './src/cache.js'
|
package/package.json
CHANGED
package/src/asset.js
CHANGED
|
@@ -7,25 +7,55 @@
|
|
|
7
7
|
import { joinUrl } from './url.js'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Join a deployed, base-relative served path to the client base (ADR-0011).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* The catalog already carries the path — `meta.url` for a file, or
|
|
13
|
+
* `meta.presets.<name>` for a transcoded derivative (the assets() plugin
|
|
14
|
+
* stamps them). The SDK no longer *constructs* `/assets/<preset>/<source>`
|
|
15
|
+
* client-side; it only prefixes the base. One rule for every served
|
|
16
|
+
* reference, files and derivatives alike:
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* (a poster preset turns .mp4 → .jpg); omit it to keep the source ext.
|
|
17
|
-
* `baseUrl` is optional — omit for a same-origin, root-relative URL.
|
|
18
|
+
* url(product.image.meta.url) // <base>/img/products/X.jpg
|
|
19
|
+
* url(product.video.meta.presets.poster) // <base>/assets/poster/…X.jpg
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* `baseUrl` empty → same-origin, root-relative. An already-absolute ref
|
|
22
|
+
* (a render baked the origin in) passes through untouched.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} ref A base-relative served path, e.g. `/img/x.jpg`.
|
|
25
|
+
* @param {{ baseUrl?: string }} [options]
|
|
22
26
|
* @returns {string}
|
|
23
27
|
*/
|
|
24
|
-
export function
|
|
25
|
-
if (!
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
export function deployedUrl(ref, { baseUrl = '' } = {}) {
|
|
29
|
+
if (!ref) return ''
|
|
30
|
+
if (/^https?:\/\//i.test(ref)) return ref
|
|
31
|
+
return baseUrl ? joinUrl(baseUrl, ref) : ref
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Dev-mode safety net (ADR-0011 Part E). Warns when an `<img>` / `<video>`
|
|
36
|
+
* failed to load — the signature of a served-file URL that hit the app
|
|
37
|
+
* origin and got the SPA's HTML fallback (`text/html` can't decode as an
|
|
38
|
+
* image → an `error` event), i.e. a missing base prefix or an unexpanded
|
|
39
|
+
* served-entity reference. Capture phase, because media `error` events
|
|
40
|
+
* don't bubble. Returns a teardown function; no-op outside a browser.
|
|
41
|
+
*
|
|
42
|
+
* if (import.meta.env.DEV) watchAssetFallbacks()
|
|
43
|
+
*/
|
|
44
|
+
export function watchAssetFallbacks({ doc = globalThis.document, warn = console.warn } = {}) {
|
|
45
|
+
if (!doc || typeof doc.addEventListener !== 'function') return () => {}
|
|
46
|
+
function onError(event) {
|
|
47
|
+
const el = event.target
|
|
48
|
+
if (!el || (el.tagName !== 'IMG' && el.tagName !== 'VIDEO')) return
|
|
49
|
+
const src = el.currentSrc || el.src || el.poster
|
|
50
|
+
if (!src) return
|
|
51
|
+
warn(
|
|
52
|
+
`[mikser] asset failed to load: ${src}\n` +
|
|
53
|
+
` Did it resolve to the SPA fallback (text/html)? Likely a missing ` +
|
|
54
|
+
`base prefix (use url(ref)) or an unexpanded served-entity reference (ADR-0011).`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
doc.addEventListener('error', onError, true)
|
|
58
|
+
return () => doc.removeEventListener('error', onError, true)
|
|
29
59
|
}
|
|
30
60
|
|
|
31
61
|
/**
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Local request cache — dedupe + memoize over an entities client's list(),
|
|
2
|
+
// for content read repeatedly that changes rarely (system docs, nav,
|
|
3
|
+
// settings, prices). The lightweight tier next to live(): live() is an
|
|
4
|
+
// always-fresh SSE subscription; this is load-once with explicit
|
|
5
|
+
// invalidation. Pairs with the live href index too — meta()/useHref is
|
|
6
|
+
// always-fresh; this is load-once, expand-capable, and readable from
|
|
7
|
+
// non-component code (Pinia stores, plain modules), because it's a plain
|
|
8
|
+
// factory, not a composable.
|
|
9
|
+
//
|
|
10
|
+
// Keyed by the WHOLE query — filter/sort/fields/expand/limit/skip/page — so
|
|
11
|
+
// a with-expand and a without-expand read of the same filter are distinct
|
|
12
|
+
// entries. Same identity rule cacheKeyFor (SDK) and cacheNameForQueryString
|
|
13
|
+
// (api) already follow; a href-only key lets a no-expand load shadow an
|
|
14
|
+
// expanded one (the gpoint bug that motivated this).
|
|
15
|
+
//
|
|
16
|
+
// Framework SDKs wrap this with their reactive primitive so a sync peek()
|
|
17
|
+
// re-evaluates when an entry lands — see createReactiveCache in -vue/-react/
|
|
18
|
+
// -svelte.
|
|
19
|
+
|
|
20
|
+
// Deterministic JSON: object keys sorted recursively, so two equivalent
|
|
21
|
+
// queries (key order aside) produce the same cache key.
|
|
22
|
+
function stableStringify(value) {
|
|
23
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
|
|
24
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
|
|
25
|
+
const keys = Object.keys(value).sort()
|
|
26
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cacheKey(query = {}) {
|
|
30
|
+
const { filter = null, sort = null, fields = null, expand = null, limit = null, skip = null, page = null } = query
|
|
31
|
+
return stableStringify({ filter, sort, fields, expand, limit, skip, page })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* createCache(docs) — a memoized, deduped view over docs.list().
|
|
36
|
+
*
|
|
37
|
+
* const cache = createCache(client.entities('public'))
|
|
38
|
+
* await cache.get({ filter: {...}, expand: [...] }) // fetch + memoize
|
|
39
|
+
* cache.peek(query) // sync: envelope | undefined
|
|
40
|
+
* cache.invalidate(query) // drop one cache.invalidate() // drop all
|
|
41
|
+
* const off = cache.subscribe(() => …) // notified on any change
|
|
42
|
+
*
|
|
43
|
+
* `get` returns the same envelope shape as list() ({ items, total, … }).
|
|
44
|
+
* Concurrent get()s for the same query share one in-flight request. A
|
|
45
|
+
* failed get() is not memoized (the next call retries).
|
|
46
|
+
*
|
|
47
|
+
* @param {{ list: (query: object, opts?: object) => Promise<object> }} docs
|
|
48
|
+
*/
|
|
49
|
+
export function createCache(docs) {
|
|
50
|
+
if (!docs || typeof docs.list !== 'function') {
|
|
51
|
+
throw new Error('createCache: pass an entities client (got something without .list)')
|
|
52
|
+
}
|
|
53
|
+
const store = new Map()
|
|
54
|
+
const inflight = new Map()
|
|
55
|
+
const listeners = new Set()
|
|
56
|
+
const notify = () => { for (const cb of listeners) { try { cb() } catch { /* listener errors are not the cache's problem */ } } }
|
|
57
|
+
|
|
58
|
+
function get(query = {}, opts = {}) {
|
|
59
|
+
const k = cacheKey(query)
|
|
60
|
+
if (store.has(k)) return Promise.resolve(store.get(k))
|
|
61
|
+
if (inflight.has(k)) return inflight.get(k)
|
|
62
|
+
const p = Promise.resolve(docs.list(query, opts))
|
|
63
|
+
.then(env => {
|
|
64
|
+
store.set(k, env)
|
|
65
|
+
inflight.delete(k)
|
|
66
|
+
notify()
|
|
67
|
+
return env
|
|
68
|
+
})
|
|
69
|
+
.catch(err => { inflight.delete(k); throw err })
|
|
70
|
+
inflight.set(k, p)
|
|
71
|
+
return p
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function peek(query = {}) { return store.get(cacheKey(query)) }
|
|
75
|
+
function has(query = {}) { return store.has(cacheKey(query)) }
|
|
76
|
+
|
|
77
|
+
function invalidate(query) {
|
|
78
|
+
if (query === undefined) { store.clear() }
|
|
79
|
+
else { store.delete(cacheKey(query)) }
|
|
80
|
+
notify()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function subscribe(cb) { listeners.add(cb); return () => { listeners.delete(cb) } }
|
|
84
|
+
|
|
85
|
+
return { get, peek, has, invalidate, subscribe, key: cacheKey }
|
|
86
|
+
}
|