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 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
- export interface AssetUrlOptions {
315
- /** Origin of the mikser server; omit for a root-relative URL. */
316
- baseUrl?: string
317
- /** Preset output format replaces the source extension (.mp4 → .jpg). */
318
- ext?: string
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
- * URL of a transcoded derivative, by the assets() plugin convention:
323
- * `<baseUrl>/assets/<preset>/<source>`. Format-neutral (video, image,
324
- * pdf, audio, …) — mikser's assets() is a preset transcoder, not an
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 assetUrl(source: string, preset: string, options?: AssetUrlOptions): string
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 — createAssetIndex (asset metadata lookup)
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, assetUrl } from './src/asset.js'
69
+ export { createAssetIndex, deployedUrl, watchAssetFallbacks } from './src/asset.js'
70
+ export { createCache, cacheKey } from './src/cache.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-sdk-api",
3
- "version": "3.5.1",
3
+ "version": "3.7.0",
4
4
  "description": "Client SDK for mikser-io's api plugin — query the document catalog from the browser or Node",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/src/asset.js CHANGED
@@ -7,25 +7,55 @@
7
7
  import { joinUrl } from './url.js'
8
8
 
9
9
  /**
10
- * URL of a transcoded derivative, by the assets() plugin convention:
10
+ * Join a deployed, base-relative served path to the client base (ADR-0011).
11
11
  *
12
- * <baseUrl>/assets/<preset>/<source>
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
- * `source` is the source ref, e.g. `/media/bg/clip.mp4`. `ext`, when
15
- * given, is the preset's output format and REPLACES the source extension
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
- * @param {string} source
20
- * @param {string} preset
21
- * @param {{ baseUrl?: string, ext?: string }} [options]
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 assetUrl(source, preset, { baseUrl = '', ext } = {}) {
25
- if (!source || !preset) return ''
26
- const file = ext ? source.replace(/\.[^./]+$/, `.${ext}`) : source
27
- const path = `/assets/${preset}/${file.replace(/^\/+/, '')}`
28
- return baseUrl ? joinUrl(baseUrl, path) : path
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
+ }