mikser-io-sdk-api 2.3.0 → 2.5.1
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 +113 -128
- package/index.d.ts +33 -0
- package/package.json +8 -2
- package/src/entities.js +316 -11
package/README.md
CHANGED
|
@@ -8,6 +8,14 @@ For semantic search against the `vector` plugin, install [mikser-io-sdk-vector](
|
|
|
8
8
|
|
|
9
9
|
Zero dependencies. Runs anywhere `fetch` is available (modern browsers, Node 18+, Deno, Bun, Workers).
|
|
10
10
|
|
|
11
|
+
> **Using Vue, React, or Svelte?** You probably want one of the framework SDKs — they wrap this package in framework-idiomatic primitives (`useDocument` / `useDocuments`, multilingual `useHref`, live SSE-driven updates) so you don't write a watch loop or lifecycle plumbing yourself:
|
|
12
|
+
>
|
|
13
|
+
> - [`mikser-io-sdk-vue`](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) — Vue 3 composables + vue-router integration
|
|
14
|
+
> - [`mikser-io-sdk-react`](https://github.com/almero-digital-marketing/mikser-io-sdk-react) — React 18+ / 19+ hooks + React Router v6+
|
|
15
|
+
> - [`mikser-io-sdk-svelte`](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) — Svelte 5 (runes) + SvelteKit
|
|
16
|
+
>
|
|
17
|
+
> Use **this** package directly when you're writing a custom adapter for another framework (Solid, Qwik, vanilla JS, server-side Node) or when you need the lower-level surface (`list`, `urlFor`, `watch`, `render`).
|
|
18
|
+
|
|
11
19
|
## Install
|
|
12
20
|
|
|
13
21
|
```bash
|
|
@@ -95,7 +103,94 @@ Operations outside the endpoint's allowlist return `403`; missing or wrong token
|
|
|
95
103
|
|
|
96
104
|
## Entities
|
|
97
105
|
|
|
98
|
-
`mikser.entities(endpointName,
|
|
106
|
+
`mikser.entities(endpointName, options)` returns a per-endpoint client. The endpoint name matches a key in your `api.endpoints` config on the server. Supported options:
|
|
107
|
+
|
|
108
|
+
| Option | Default | What it does |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `token` | `null` | Bearer token for endpoints declared with a token. Sent on every request. |
|
|
111
|
+
| `initialUrl` | `null` | Static-snapshot URL — see below. |
|
|
112
|
+
| `fallbackToList` | `true` | If `initialUrl` is set and the fetch fails (404 in dev, wrong shape, network error), fall back to a live `list()`. Set `false` for production deploys where missing the snapshot should be a hard error. |
|
|
113
|
+
|
|
114
|
+
### `initialUrl` — pair with the `data` plugin for fast first paint
|
|
115
|
+
|
|
116
|
+
If you have a known, predictable query that runs on every page load — a route table for an SPA, a navigation menu, a category list — paying an API round-trip for it on first paint is wasted work. The right shape is to publish that data as a static file at build/finalize time and have the SDK pick it up.
|
|
117
|
+
|
|
118
|
+
Mikser's `data` plugin does the publishing side. A `catalog.<name>` entry writes one JSON file per name under `out/data/`; `<name>` is whatever you want to call it — name it after the role the snapshot plays in your app (`sitemap`, `menu`, `tags`, `categories`, etc.). Example for a router-driving snapshot:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
// mikser.config.js
|
|
122
|
+
plugins: ['documents', 'front-matter', 'data', 'api', /* ... */],
|
|
123
|
+
|
|
124
|
+
data: {
|
|
125
|
+
catalog: {
|
|
126
|
+
// out/data/sitemap.json — one entry per published, component-having
|
|
127
|
+
// document, projected to just the routing fields. `sitemap` is the
|
|
128
|
+
// catalog-entry name; the SDK below uses the matching URL path.
|
|
129
|
+
sitemap: {
|
|
130
|
+
query: e => e.type === 'document' && e.meta?.published && e.meta?.component,
|
|
131
|
+
pick: ['id', 'destination', 'meta.component', 'meta.route', 'meta.title'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
api: {
|
|
137
|
+
endpoints: {
|
|
138
|
+
public: {
|
|
139
|
+
query: e => e.type === 'document' && e.meta?.published,
|
|
140
|
+
operations: ['list', 'subscribe'],
|
|
141
|
+
cache: true,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
On the client, point `entities()` at the matching file. The URL path mirrors the catalog name: `data.catalog.sitemap` → `/data/sitemap.json`, `data.catalog.menu` → `/data/menu.json`, and so on.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const documents = createClient({ baseUrl: 'https://cms.example.com' })
|
|
151
|
+
.entities('public', { initialUrl: '/data/sitemap.json' })
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
What `initialUrl` changes:
|
|
155
|
+
|
|
156
|
+
- **`live(filter, onChange, options)`** fires `onChange` with the snapshot immediately (no API round-trip), then opens the SSE subscribe stream as usual. So `useMikserRoutes` / `useMikserPages` get a populated route table before the network even settles.
|
|
157
|
+
- **`listAll()`** consults the snapshot first and only falls back to paginated `list()` calls if the snapshot is missing or `fallbackToList: true` and the fetch failed.
|
|
158
|
+
- **`list()`**, `urlFor()`, `query()`, `update()`, `delete()`, `render()`, `subscribe()` are unchanged — they always go to the API.
|
|
159
|
+
|
|
160
|
+
The data plugin emits each entry as `{ refId, name, date, data: {...picked} }`. The SDK strips that wrapper automatically and hands `onChange` / `listAll` a plain array of the `data` payloads, so a static snapshot looks identical to a live response.
|
|
161
|
+
|
|
162
|
+
This is **not** a cache. The snapshot is only consulted for the initial fill; ongoing changes come over SSE on the actual API endpoint. For runtime fail-safety on per-id reads, that's what the api plugin's `cache: true` is for — see [mikser-io's caching docs](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/caching.md).
|
|
163
|
+
|
|
164
|
+
**Edge case: first-paint flash for fast-changing snapshots.** First paint renders the snapshot — which may be N seconds old, depending on when mikser last wrote it — and the SSE stream then arrives and reconciles any changes since. For a route table that's invisible; routes don't move every second. For snapshots of fast-changing data (recent activity, in-stock badges, live counters) the user may briefly see stale content before SSE catches up. If that flash is visible UX, either design for it (e.g. show a subtle "syncing" indicator until the first SSE event arrives, or render a loading state when the snapshot age exceeds your tolerance) or skip `initialUrl` for that particular query — paying the API roundtrip is the right tradeoff when the response can't be allowed to be stale.
|
|
165
|
+
|
|
166
|
+
**Snapshot-bypass warning.** Snapshots only apply when the `live()` / `listAll()` call is trivial — no filter, no sort, no skip. Add any of those and the SDK silently falls back to the live API. Since that's the kind of thing a developer can change without noticing, the SDK emits a one-time `console.warn` per `(endpoint, call kind, what-was-set)` shape:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
[mikser-sdk] initialUrl is set on "public" but this live() call uses filter+sort
|
|
170
|
+
— snapshot bypassed, falling back to live list().
|
|
171
|
+
Snapshots only apply when the call is trivial (no filter/sort/skip).
|
|
172
|
+
Either remove the filter+sort from this call, or accept the API roundtrip if
|
|
173
|
+
filtering is intentional.
|
|
174
|
+
Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Same suppression channels as the wide-list warning above.
|
|
178
|
+
|
|
179
|
+
### Dev-mode warning: accidentally wide queries
|
|
180
|
+
|
|
181
|
+
The common failure mode for a CMS-backed app is "I just wanted a nav menu but pulled every full document over the wire." To catch it at write time, `list()` and `live()` emit a one-time dev-mode `console.warn` when a response has more than 50 items and the query has no `fields:` projection:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
[mikser-sdk] list() returned 247 items (~4.2 MB) from "public" with no `fields` projection.
|
|
185
|
+
Add { fields: [...] } to narrow it, or — if this query runs on every page load —
|
|
186
|
+
move it to a `data.catalog.<name>` snapshot on the mikser side and load via:
|
|
187
|
+
entities('public', { initialUrl: '/data/<name>.json' })
|
|
188
|
+
Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The warning is deduped per `(endpoint, filter, sort)` shape, so an SSE-driven list that updates 30 times only fires once. It's silenced when `process.env.NODE_ENV === 'production'`, when `MIKSER_QUIET` is set, or per-call via `{ quiet: true }` on `list(query, opts)` / `live(filter, onChange, opts)`.
|
|
192
|
+
|
|
193
|
+
The mikser-io server emits a matching warning in its logs for the same shape — useful when the wide query came from curl, another SDK, or an environment where `console` isn't visible.
|
|
99
194
|
|
|
100
195
|
### `list(query)` — body-based
|
|
101
196
|
|
|
@@ -206,9 +301,9 @@ Equivalent to:
|
|
|
206
301
|
// 4. abort on dispose
|
|
207
302
|
```
|
|
208
303
|
|
|
209
|
-
…but with race-safe cleanup (no `mounted` flag needed in caller code), unified error routing via `onError`, and a single dispose path. This is the building block the framework
|
|
304
|
+
…but with race-safe cleanup (no `mounted` flag needed in caller code), unified error routing via `onError`, and a single dispose path. This is the building block the [framework SDKs](#framework-integration) (Vue / React / Svelte) consume internally — and the surface to use directly if you're writing a custom adapter.
|
|
210
305
|
|
|
211
|
-
`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
|
|
306
|
+
`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.
|
|
212
307
|
|
|
213
308
|
### `update(payload)` / `delete(payload)` — writes
|
|
214
309
|
|
|
@@ -443,142 +538,32 @@ Two SDKs, one mental model, one server. The vector store gives you ranked semant
|
|
|
443
538
|
|
|
444
539
|
### Framework integration
|
|
445
540
|
|
|
446
|
-
All the boilerplate (initial fetch, watch loop, race-safe cleanup) lives inside `
|
|
541
|
+
All the boilerplate (initial fetch, watch loop, race-safe cleanup) lives inside `live()`. A framework adapter is ~5 lines — it gives the SDK a callback and calls dispose on unmount. For the three major frameworks, those adapters are already published:
|
|
447
542
|
|
|
448
|
-
|
|
543
|
+
| Framework | Package | Primitive |
|
|
544
|
+
|---|---|---|
|
|
545
|
+
| Vue 3 | [`mikser-io-sdk-vue`](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) | `useDocument(id)` / `useDocuments(query)` returning Vue refs; vue-router integration via `createMikserRouter` |
|
|
546
|
+
| React 18+ / 19+ | [`mikser-io-sdk-react`](https://github.com/almero-digital-marketing/mikser-io-sdk-react) | `useDocument(id)` / `useDocuments(query)` hooks; React Router integration via `useMikserRoutes` → `useRoutes` |
|
|
547
|
+
| Svelte 5 | [`mikser-io-sdk-svelte`](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) | `useDocument(() => id)` / `useDocuments(() => query)` runes-backed reactives; SvelteKit `entries()` integration via `generateMikserRoutes` |
|
|
449
548
|
|
|
450
|
-
|
|
451
|
-
// mikser.js — single source of truth for the configured client
|
|
452
|
-
import { createClient } from 'mikser-io-sdk-api'
|
|
453
|
-
export const docs = createClient({ baseUrl: 'https://cms.example.com' })
|
|
454
|
-
.entities('public')
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
#### React (hook)
|
|
458
|
-
|
|
459
|
-
```js
|
|
460
|
-
// useLiveEntities.js
|
|
461
|
-
import { useEffect, useState } from 'react'
|
|
462
|
-
import { docs } from './mikser'
|
|
463
|
-
|
|
464
|
-
export function useLiveEntities(filter, options) {
|
|
465
|
-
const [items, setItems] = useState([])
|
|
466
|
-
useEffect(
|
|
467
|
-
() => docs.live(filter, setItems, options), // returns dispose
|
|
468
|
-
[JSON.stringify(filter)],
|
|
469
|
-
)
|
|
470
|
-
return items
|
|
471
|
-
}
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
```jsx
|
|
475
|
-
// ArticleList.jsx
|
|
476
|
-
import { useLiveEntities } from './useLiveEntities'
|
|
477
|
-
|
|
478
|
-
export function ArticleList() {
|
|
479
|
-
const articles = useLiveEntities(
|
|
480
|
-
{ type: 'document', 'meta.collection': 'articles', 'meta.published': true },
|
|
481
|
-
{ sort: { 'meta.date': -1 }, limit: 20 },
|
|
482
|
-
)
|
|
483
|
-
return (
|
|
484
|
-
<ul>
|
|
485
|
-
{articles.map(a => <li key={a.id}>{a.meta.title}</li>)}
|
|
486
|
-
</ul>
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
```
|
|
549
|
+
All three share the same conceptual surface — single-document subscription, list subscription, multilingual `useHref` / `useAlternates`, asset resolution via `useAsset` — wrapped in each framework's idiomatic shape. They all peer-depend on this package and consume `live()` internally; nothing about their behaviour is duplicated logic. If you have one of those three frameworks, prefer the matching SDK over hand-rolling against `live()`.
|
|
490
550
|
|
|
491
|
-
|
|
551
|
+
The shape adapts to **any** framework with a setup-and-cleanup lifecycle — Solid (`createSignal` + `onCleanup`), Qwik (`useTask$`), Lit, or vanilla JS. For those, the adapter pattern is the same five lines: instantiate state, call `live(filter, setState)`, store the returned `dispose`, call it on teardown.
|
|
492
552
|
|
|
493
553
|
```js
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
import { docs } from './mikser'
|
|
497
|
-
|
|
498
|
-
export function useLiveEntities(filter, options) {
|
|
499
|
-
const items = ref([])
|
|
500
|
-
let dispose
|
|
501
|
-
onMounted (() => { dispose = docs.live(filter, v => items.value = v, options) })
|
|
502
|
-
onUnmounted(() => dispose?.())
|
|
503
|
-
return { items }
|
|
504
|
-
}
|
|
505
|
-
```
|
|
554
|
+
// vanilla adapter shape — works in any environment
|
|
555
|
+
const documents = createClient({ baseUrl }).entities('public')
|
|
506
556
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
import { useLiveEntities } from './useLiveEntities'
|
|
511
|
-
|
|
512
|
-
const { items: articles } = useLiveEntities(
|
|
513
|
-
{ type: 'document', 'meta.collection': 'articles', 'meta.published': true },
|
|
557
|
+
const dispose = documents.live(
|
|
558
|
+
{ 'meta.published': true },
|
|
559
|
+
(items) => render(items), // your update callback
|
|
514
560
|
{ sort: { 'meta.date': -1 }, limit: 20 },
|
|
515
561
|
)
|
|
516
|
-
</script>
|
|
517
|
-
|
|
518
|
-
<template>
|
|
519
|
-
<ul>
|
|
520
|
-
<li v-for="a in articles" :key="a.id">{{ a.meta.title }}</li>
|
|
521
|
-
</ul>
|
|
522
|
-
</template>
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
#### Svelte (writable store — works in Svelte 3, 4, and 5)
|
|
526
|
-
|
|
527
|
-
Svelte's `writable(initial, start)` pattern is a perfect fit: `start` runs when the store gains its first subscriber and the returned `stop` runs when the last one disappears. The store lifecycle and `live()`'s dispose function line up exactly.
|
|
528
|
-
|
|
529
|
-
```js
|
|
530
|
-
// liveEntities.js
|
|
531
|
-
import { writable } from 'svelte/store'
|
|
532
|
-
import { docs } from './mikser'
|
|
533
|
-
|
|
534
|
-
export function liveEntities(filter, options) {
|
|
535
|
-
return writable([], (set) => docs.live(filter, set, options))
|
|
536
|
-
}
|
|
537
|
-
```
|
|
538
562
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
<script>
|
|
542
|
-
import { liveEntities } from './liveEntities'
|
|
543
|
-
|
|
544
|
-
const articles = liveEntities(
|
|
545
|
-
{ type: 'document', 'meta.collection': 'articles', 'meta.published': true },
|
|
546
|
-
{ sort: { 'meta.date': -1 }, limit: 20 },
|
|
547
|
-
)
|
|
548
|
-
</script>
|
|
549
|
-
|
|
550
|
-
<ul>
|
|
551
|
-
{#each $articles as a (a.id)}
|
|
552
|
-
<li>{a.meta.title}</li>
|
|
553
|
-
{/each}
|
|
554
|
-
</ul>
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
If you're on Svelte 5 and prefer runes over stores:
|
|
558
|
-
|
|
559
|
-
```svelte
|
|
560
|
-
<!-- ArticleList.svelte (Svelte 5 runes) -->
|
|
561
|
-
<script>
|
|
562
|
-
import { onMount } from 'svelte'
|
|
563
|
-
import { docs } from './mikser'
|
|
564
|
-
|
|
565
|
-
let articles = $state([])
|
|
566
|
-
const filter = { type: 'document', 'meta.collection': 'articles', 'meta.published': true }
|
|
567
|
-
|
|
568
|
-
onMount(() => docs.live(filter, v => articles = v, {
|
|
569
|
-
sort: { 'meta.date': -1 }, limit: 20,
|
|
570
|
-
}))
|
|
571
|
-
</script>
|
|
572
|
-
|
|
573
|
-
<ul>
|
|
574
|
-
{#each articles as a (a.id)}
|
|
575
|
-
<li>{a.meta.title}</li>
|
|
576
|
-
{/each}
|
|
577
|
-
</ul>
|
|
563
|
+
// later, on teardown
|
|
564
|
+
dispose()
|
|
578
565
|
```
|
|
579
566
|
|
|
580
|
-
The same shape adapts to Solid (`createSignal` + `onCleanup`), Qwik (`useTask$`), or vanilla JS — anywhere with a setup-and-cleanup lifecycle. The SDK doesn't care.
|
|
581
|
-
|
|
582
567
|
## Configure
|
|
583
568
|
|
|
584
569
|
```js
|
package/index.d.ts
CHANGED
|
@@ -16,6 +16,32 @@ export interface ClientOptions {
|
|
|
16
16
|
export interface EntityOptions {
|
|
17
17
|
/** Bearer token sent on every request to this endpoint. */
|
|
18
18
|
token?: string
|
|
19
|
+
/**
|
|
20
|
+
* URL (relative to baseUrl or absolute) of a pre-built static JSON
|
|
21
|
+
* snapshot of the entity set. When set, `live()` and `listAll()`
|
|
22
|
+
* read from this URL on first call instead of hitting the live API.
|
|
23
|
+
*
|
|
24
|
+
* Largely obsolete with the api plugin's per-query disk cache
|
|
25
|
+
* (mikser-io ^6.25.0) — the cache writes to the same URL the live
|
|
26
|
+
* API serves, and a reverse proxy can fail over to it transparently.
|
|
27
|
+
* Use `initialUrl` only when:
|
|
28
|
+
* - the snapshot URL is a different host (CDN, edge cache,
|
|
29
|
+
* pre-rendered static asset on disk)
|
|
30
|
+
* - you want to skip the live API roundtrip on boot even when
|
|
31
|
+
* mikser is up (microoptimization)
|
|
32
|
+
*
|
|
33
|
+
* Otherwise, just configure your reverse proxy to fail over to the
|
|
34
|
+
* cache on backend errors and the live URL Just Works.
|
|
35
|
+
*/
|
|
36
|
+
initialUrl?: string
|
|
37
|
+
/**
|
|
38
|
+
* When the snapshot fetch fails (404 in dev, network error,
|
|
39
|
+
* unrecognised shape), default behaviour is to silently fall
|
|
40
|
+
* back to a fresh `list()` call. Set false for production
|
|
41
|
+
* environments where you require the snapshot to be present.
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
fallbackToList?: boolean
|
|
19
45
|
}
|
|
20
46
|
|
|
21
47
|
/**
|
|
@@ -115,6 +141,13 @@ export interface EntitiesClient {
|
|
|
115
141
|
urlFor(query?: ListQuery): string
|
|
116
142
|
/** Iterate result pages — yields each envelope until hasNext is false. */
|
|
117
143
|
pages<T = unknown>(query?: ListQuery): AsyncGenerator<ListEnvelope<T>>
|
|
144
|
+
/**
|
|
145
|
+
* One-shot: fetch every matching entity into a flat array.
|
|
146
|
+
* Auto-paginates internally; `limit` controls per-page batch size
|
|
147
|
+
* (default 1000), not total cap. Right for SSG enumeration; wrong
|
|
148
|
+
* for catalogs too large to hold in memory.
|
|
149
|
+
*/
|
|
150
|
+
listAll<T = unknown>(query?: ListQuery): Promise<T[]>
|
|
118
151
|
/**
|
|
119
152
|
* Open an SSE stream and yield events as matching entities change.
|
|
120
153
|
* Compose with list() for initial state, then watch() for updates.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-sdk-api",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
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",
|
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
"README.md",
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
|
-
"scripts": {
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "vitest",
|
|
23
|
+
"test:run": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"vitest": "^2.0.0"
|
|
27
|
+
},
|
|
22
28
|
"repository": {
|
|
23
29
|
"type": "git",
|
|
24
30
|
"url": "git+https://github.com/almero-digital-marketing/mikser-io-sdk-api.git"
|
package/src/entities.js
CHANGED
|
@@ -7,20 +7,197 @@ import { bearer, jsonOrThrow } from './http.js'
|
|
|
7
7
|
import { joinUrl, sortToParam, filterToParams } from './url.js'
|
|
8
8
|
import { parseSseEvent } from './sse.js'
|
|
9
9
|
|
|
10
|
+
// Conservative URL-length ceiling for the GET form of list(). Real
|
|
11
|
+
// browsers and proxies vary (Chrome ~32k, IIS ~16k, nginx default
|
|
12
|
+
// 8k, some CDNs 4k), but the safest interop floor for list-with-
|
|
13
|
+
// many-filter-params is well under 2k. Queries past this fall back
|
|
14
|
+
// to POST automatically.
|
|
15
|
+
const GET_MAX_URL = 1800
|
|
16
|
+
|
|
17
|
+
// Wide-list warning — surfaces in dev mode when a list/live call
|
|
18
|
+
// returns more than this without a `fields` projection. Catches the
|
|
19
|
+
// common "I just wanted a nav menu but pulled every full document"
|
|
20
|
+
// failure mode at the first place it manifests (developer's DevTools
|
|
21
|
+
// console). Server-side has a matching warning that fires for all
|
|
22
|
+
// clients regardless of SDK use.
|
|
23
|
+
const WIDE_RESPONSE_ITEMS = 50
|
|
24
|
+
const _warnedShapes = new Set()
|
|
25
|
+
|
|
26
|
+
function isProductionEnv() {
|
|
27
|
+
try {
|
|
28
|
+
return typeof process !== 'undefined'
|
|
29
|
+
&& process.env?.NODE_ENV === 'production'
|
|
30
|
+
} catch { return false }
|
|
31
|
+
}
|
|
32
|
+
function isQuiet() {
|
|
33
|
+
try {
|
|
34
|
+
return typeof process !== 'undefined' && process.env?.MIKSER_QUIET
|
|
35
|
+
} catch { return false }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function maybeWarnWide({ endpoint, query, envelopeOrItems, quiet }) {
|
|
39
|
+
if (quiet || isProductionEnv() || isQuiet()) return
|
|
40
|
+
const items = Array.isArray(envelopeOrItems)
|
|
41
|
+
? envelopeOrItems
|
|
42
|
+
: envelopeOrItems?.items
|
|
43
|
+
if (!Array.isArray(items) || items.length <= WIDE_RESPONSE_ITEMS) return
|
|
44
|
+
const hasFields = Array.isArray(query?.fields) && query.fields.length > 0
|
|
45
|
+
if (hasFields) return
|
|
46
|
+
const shape = `${endpoint}|${JSON.stringify(query?.filter ?? null)}|${JSON.stringify(query?.sort ?? null)}`
|
|
47
|
+
if (_warnedShapes.has(shape)) return
|
|
48
|
+
_warnedShapes.add(shape)
|
|
49
|
+
let sizeNote = ''
|
|
50
|
+
try {
|
|
51
|
+
const bytes = JSON.stringify(items).length
|
|
52
|
+
sizeNote = bytes >= 1024 * 1024
|
|
53
|
+
? ` (~${(bytes / 1024 / 1024).toFixed(1)} MB)`
|
|
54
|
+
: ` (~${Math.round(bytes / 1024)} KB)`
|
|
55
|
+
} catch { /* size note is best-effort */ }
|
|
56
|
+
console.warn(
|
|
57
|
+
`[mikser-sdk] list() returned ${items.length} items${sizeNote} from "${endpoint}" with no \`fields\` projection.\n` +
|
|
58
|
+
` Add { fields: [...] } to narrow it, or — if this query runs on every page load —\n` +
|
|
59
|
+
` move it to a \`data.catalog.<name>\` snapshot on the mikser side and load via:\n` +
|
|
60
|
+
` entities('${endpoint}', { initialUrl: '/data/<name>.json' })\n` +
|
|
61
|
+
` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Snapshot-bypass warning — fires when `initialUrl` is configured but
|
|
66
|
+
// the call is non-trivial (has filter / sort / skip), so the snapshot
|
|
67
|
+
// can't be used and the SDK falls back to the live API. The bypass is
|
|
68
|
+
// correct behavior, but it's silent: developers often set initialUrl
|
|
69
|
+
// once and then add a sort to one of their useDocuments calls without
|
|
70
|
+
// noticing the snapshot is no longer involved. Deduped per
|
|
71
|
+
// (endpoint, kind, what-was-set) so a page with 3 filtered calls
|
|
72
|
+
// produces 3 warnings, not 30.
|
|
73
|
+
const _bypassedShapes = new Set()
|
|
74
|
+
function maybeWarnSnapshotBypass({ endpoint, kind, filter, sort, skip, quiet }) {
|
|
75
|
+
if (quiet || isProductionEnv() || isQuiet()) return
|
|
76
|
+
const reasons = []
|
|
77
|
+
if (filter) reasons.push('filter')
|
|
78
|
+
if (sort) reasons.push('sort')
|
|
79
|
+
if (skip != null) reasons.push('skip')
|
|
80
|
+
if (reasons.length === 0) return
|
|
81
|
+
const reasonLabel = reasons.join('+')
|
|
82
|
+
const shape = `${endpoint}|${kind}|${reasonLabel}`
|
|
83
|
+
if (_bypassedShapes.has(shape)) return
|
|
84
|
+
_bypassedShapes.add(shape)
|
|
85
|
+
const fallback = kind === 'live' ? 'live list()' : 'paginated fetch'
|
|
86
|
+
console.warn(
|
|
87
|
+
`[mikser-sdk] initialUrl is set on "${endpoint}" but this ${kind}() call uses ${reasonLabel} — snapshot bypassed, falling back to ${fallback}.\n` +
|
|
88
|
+
` Snapshots only apply when the call is trivial (no filter/sort/skip).\n` +
|
|
89
|
+
` Either remove the ${reasonLabel} from this call, or accept the API roundtrip if filtering is intentional.\n` +
|
|
90
|
+
` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
10
94
|
export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, headers: defaultHeaders }) {
|
|
11
|
-
return function entities(name,
|
|
95
|
+
return function entities(name, opts = {}) {
|
|
96
|
+
const {
|
|
97
|
+
token,
|
|
98
|
+
// initialUrl: optional URL (relative to baseUrl or absolute) for a
|
|
99
|
+
// pre-built static JSON snapshot — typically produced by the
|
|
100
|
+
// `data` plugin's catalog.<name> output. When set, live() fires
|
|
101
|
+
// onChange with the snapshot immediately, then opens the SSE
|
|
102
|
+
// subscribe stream as usual. listAll() also consults the
|
|
103
|
+
// snapshot before falling back to paginated list calls.
|
|
104
|
+
//
|
|
105
|
+
// The fetched JSON is unwrapped automatically — the data
|
|
106
|
+
// plugin emits `[{ refId, name, date, data }, ...]`; we
|
|
107
|
+
// return the `.data` payloads. Plain arrays and { items }
|
|
108
|
+
// envelopes are passed through unchanged.
|
|
109
|
+
//
|
|
110
|
+
// Pre-built snapshots are the fast first-paint path:
|
|
111
|
+
// CDN-cached, no API roundtrip, no SSE latency tax on boot.
|
|
112
|
+
// Pair the snapshot's data-plugin filter with your live()
|
|
113
|
+
// filter so the initial state matches what SSE will send.
|
|
114
|
+
initialUrl,
|
|
115
|
+
// When initialUrl fetch fails (404 in dev, network error,
|
|
116
|
+
// wrong shape), default behavior is to log and fall back to
|
|
117
|
+
// a fresh list() call — keeps dev mode trivial. Set false
|
|
118
|
+
// for environments where you require the snapshot.
|
|
119
|
+
fallbackToList = true,
|
|
120
|
+
} = opts
|
|
12
121
|
const endpointBase = `${basePath}/${name}`
|
|
13
122
|
const queryUrl = joinUrl(baseUrl, `${endpointBase}/entities/query`)
|
|
14
123
|
const listUrl = joinUrl(baseUrl, `${endpointBase}/entities`)
|
|
15
124
|
const subscribeUrl = joinUrl(baseUrl, `${endpointBase}/entities/subscribe`)
|
|
16
125
|
const renderUrl = joinUrl(baseUrl, `${endpointBase}/render`)
|
|
17
126
|
|
|
127
|
+
const resolvedInitialUrl = initialUrl
|
|
128
|
+
? (/^https?:\/\//.test(initialUrl) ? initialUrl : joinUrl(baseUrl, initialUrl))
|
|
129
|
+
: null
|
|
130
|
+
|
|
131
|
+
// Cached snapshot promise — concurrent live() / listAll() calls
|
|
132
|
+
// share one fetch. Cleared on error so a flaky connection can
|
|
133
|
+
// recover on the next call.
|
|
134
|
+
let snapshotPromise = null
|
|
135
|
+
async function loadSnapshot() {
|
|
136
|
+
if (!resolvedInitialUrl) return null
|
|
137
|
+
if (snapshotPromise) return snapshotPromise
|
|
138
|
+
snapshotPromise = (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const res = await doFetch(resolvedInitialUrl, {
|
|
141
|
+
method: 'GET',
|
|
142
|
+
headers: { accept: 'application/json', ...defaultHeaders },
|
|
143
|
+
})
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
if (!fallbackToList) {
|
|
146
|
+
throw new MikserError(res.status, res.statusText, null, resolvedInitialUrl)
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
const payload = await res.json()
|
|
151
|
+
return unwrapSnapshot(payload)
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// Clear the cached promise so the next call can retry.
|
|
154
|
+
snapshotPromise = null
|
|
155
|
+
if (!fallbackToList) throw err
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
})()
|
|
159
|
+
return snapshotPromise
|
|
160
|
+
}
|
|
161
|
+
|
|
18
162
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
163
|
+
* Run a list query. Returns the standard envelope:
|
|
164
|
+
* { items, page, limit, total, totalPages, hasNext, hasPrev }.
|
|
165
|
+
*
|
|
166
|
+
* Transport selection: tries GET first because GET responses
|
|
167
|
+
* are what the api plugin's per-query disk cache writes (and
|
|
168
|
+
* what a reverse proxy can serve as failover when mikser is
|
|
169
|
+
* down). Falls back to POST when:
|
|
170
|
+
* - the encoded URL exceeds GET_MAX_URL (browsers and most
|
|
171
|
+
* proxies start refusing past ~2KB; we use a conservative
|
|
172
|
+
* limit of 1800)
|
|
173
|
+
* - GET is explicitly disabled via opts.method = 'POST'
|
|
174
|
+
*
|
|
175
|
+
* GET can express anything sift accepts via the `.$op` URL-param
|
|
176
|
+
* suffix scheme (see urlFor). Use POST when you want to be
|
|
177
|
+
* explicit about not engaging the cache path, e.g. for queries
|
|
178
|
+
* with secrets in the body that shouldn't sit in proxy logs.
|
|
22
179
|
*/
|
|
23
|
-
async function list(query = {}) {
|
|
180
|
+
async function list(query = {}, opts = {}) {
|
|
181
|
+
const forcePost = opts.method === 'POST'
|
|
182
|
+
|
|
183
|
+
if (!forcePost) {
|
|
184
|
+
const url = urlFor(query)
|
|
185
|
+
if (url.length <= GET_MAX_URL) {
|
|
186
|
+
const res = await doFetch(url, {
|
|
187
|
+
method: 'GET',
|
|
188
|
+
headers: {
|
|
189
|
+
accept: 'application/json',
|
|
190
|
+
...defaultHeaders,
|
|
191
|
+
...bearer(token),
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
const envelope = await jsonOrThrow(res, url)
|
|
195
|
+
maybeWarnWide({ endpoint: name, query, envelopeOrItems: envelope, quiet: opts.quiet })
|
|
196
|
+
return envelope
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// POST fallback — long URLs and forced POST land here.
|
|
24
201
|
const res = await doFetch(queryUrl, {
|
|
25
202
|
method: 'POST',
|
|
26
203
|
headers: {
|
|
@@ -30,7 +207,9 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
30
207
|
},
|
|
31
208
|
body: JSON.stringify(query),
|
|
32
209
|
})
|
|
33
|
-
|
|
210
|
+
const envelope = await jsonOrThrow(res, queryUrl)
|
|
211
|
+
maybeWarnWide({ endpoint: name, query, envelopeOrItems: envelope, quiet: opts.quiet })
|
|
212
|
+
return envelope
|
|
34
213
|
}
|
|
35
214
|
|
|
36
215
|
/**
|
|
@@ -64,6 +243,48 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
64
243
|
}
|
|
65
244
|
}
|
|
66
245
|
|
|
246
|
+
/**
|
|
247
|
+
* One-shot: fetch every entity matching the filter into a flat
|
|
248
|
+
* array. Auto-paginates internally via pages() — `limit` in the
|
|
249
|
+
* query controls the per-page batch size (default 1000), not the
|
|
250
|
+
* total cap.
|
|
251
|
+
*
|
|
252
|
+
* Right when: SSG route enumeration, sitemap generation, build-
|
|
253
|
+
* time indexing — anything that needs the whole filtered set in
|
|
254
|
+
* memory at once.
|
|
255
|
+
*
|
|
256
|
+
* Wrong when: the catalog is large enough that loading all of it
|
|
257
|
+
* is wasteful. Use pages() directly and stream-process there.
|
|
258
|
+
*/
|
|
259
|
+
async function listAll(query = {}) {
|
|
260
|
+
// Snapshot fast path: if no filter/sort/skip is requested
|
|
261
|
+
// beyond what the snapshot was built with, return the
|
|
262
|
+
// pre-built array directly. The caller's filter/sort would
|
|
263
|
+
// require re-querying the live endpoint anyway, so any
|
|
264
|
+
// non-trivial query falls through to the paginated fetch.
|
|
265
|
+
const trivial = !query.filter && !query.sort && !query.skip
|
|
266
|
+
if (trivial && resolvedInitialUrl) {
|
|
267
|
+
const snapshot = await loadSnapshot()
|
|
268
|
+
if (snapshot) {
|
|
269
|
+
return query.fields
|
|
270
|
+
? snapshot.map(item => pickFields(item, query.fields))
|
|
271
|
+
: snapshot
|
|
272
|
+
}
|
|
273
|
+
// Snapshot unavailable — fall through to paginated fetch.
|
|
274
|
+
}
|
|
275
|
+
if (!trivial && resolvedInitialUrl) {
|
|
276
|
+
maybeWarnSnapshotBypass({
|
|
277
|
+
endpoint: name, kind: 'listAll',
|
|
278
|
+
filter: query.filter, sort: query.sort, skip: query.skip,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
const items = []
|
|
282
|
+
for await (const env of pages({ limit: 1000, ...query })) {
|
|
283
|
+
items.push(...env.items)
|
|
284
|
+
}
|
|
285
|
+
return items
|
|
286
|
+
}
|
|
287
|
+
|
|
67
288
|
/**
|
|
68
289
|
* Subscribe to changes — opens an SSE stream and yields events
|
|
69
290
|
* for each matching entity change (CREATE / UPDATE / DELETE).
|
|
@@ -149,6 +370,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
149
370
|
function live(filter, onChange, options = {}) {
|
|
150
371
|
const {
|
|
151
372
|
sort, fields, limit, skip,
|
|
373
|
+
quiet,
|
|
152
374
|
signal: externalSignal,
|
|
153
375
|
onError = (err) => console.error('mikser-io-sdk-api live error:', err),
|
|
154
376
|
} = options
|
|
@@ -164,10 +386,40 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
164
386
|
|
|
165
387
|
const loop = (async () => {
|
|
166
388
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
389
|
+
// Snapshot fast path. Use it only when the caller's
|
|
390
|
+
// query is trivial enough that the pre-built array
|
|
391
|
+
// reflects what list() would return — anything more
|
|
392
|
+
// specific (filter, sort, skip) goes through list()
|
|
393
|
+
// so the caller's intent is honored.
|
|
394
|
+
let usedSnapshot = false
|
|
395
|
+
const trivial = !filter && !sort && !skip
|
|
396
|
+
if (trivial && resolvedInitialUrl) {
|
|
397
|
+
const snapshot = await loadSnapshot()
|
|
398
|
+
if (snapshot) {
|
|
399
|
+
if (disposed || ac.signal.aborted) return
|
|
400
|
+
items = fields ? snapshot.map(i => pickFields(i, fields)) : snapshot
|
|
401
|
+
onChange(items)
|
|
402
|
+
usedSnapshot = true
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!trivial && resolvedInitialUrl) {
|
|
406
|
+
maybeWarnSnapshotBypass({
|
|
407
|
+
endpoint: name, kind: 'live',
|
|
408
|
+
filter, sort, skip, quiet,
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
if (!usedSnapshot) {
|
|
412
|
+
// Pass { quiet } so list()'s wide-warning honors
|
|
413
|
+
// the live() caller's quiet opt. live() also
|
|
414
|
+
// does its own snapshot-path warning below for
|
|
415
|
+
// the case where the initial fill came from
|
|
416
|
+
// /data/<name>.json — those callers already
|
|
417
|
+
// opted into the narrow shape, so no warning.
|
|
418
|
+
const env = await list({ filter, sort, fields, limit, skip }, { quiet })
|
|
419
|
+
if (disposed || ac.signal.aborted) return
|
|
420
|
+
items = env.items
|
|
421
|
+
onChange(items)
|
|
422
|
+
}
|
|
171
423
|
|
|
172
424
|
for await (const event of watch({ filter }, { signal: ac.signal })) {
|
|
173
425
|
if (disposed) return
|
|
@@ -264,6 +516,59 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
264
516
|
return res.arrayBuffer()
|
|
265
517
|
}
|
|
266
518
|
|
|
267
|
-
return { list, urlFor, pages, watch, live, update, delete: remove, render }
|
|
519
|
+
return { list, listAll, urlFor, pages, watch, live, update, delete: remove, render }
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Unwrap a snapshot payload into a plain array of entity-like objects.
|
|
524
|
+
// Recognises three shapes:
|
|
525
|
+
// - data-plugin catalog output: [{ refId, name, date, data }, ...]
|
|
526
|
+
// - plain array of entities: [{ id, meta, ... }, ...]
|
|
527
|
+
// - list() envelope: { items: [...], page, ... }
|
|
528
|
+
// Returns null on unrecognised shapes so the caller can decide to fall
|
|
529
|
+
// back to a fresh list() call (when fallbackToList is true).
|
|
530
|
+
function unwrapSnapshot(payload) {
|
|
531
|
+
if (Array.isArray(payload)) {
|
|
532
|
+
if (payload.length === 0) return []
|
|
533
|
+
// Heuristic: data-plugin entries have `refId` + `data`; treat any
|
|
534
|
+
// object with `data` as a wrapped entry and unwrap. Anything else
|
|
535
|
+
// is a plain array.
|
|
536
|
+
if (payload[0] && typeof payload[0] === 'object' && 'data' in payload[0]) {
|
|
537
|
+
return payload.map(entry => entry.data).filter(Boolean)
|
|
538
|
+
}
|
|
539
|
+
return payload
|
|
540
|
+
}
|
|
541
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.items)) {
|
|
542
|
+
return payload.items
|
|
543
|
+
}
|
|
544
|
+
return null
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Project an entity object to only the fields requested. Dotted paths
|
|
548
|
+
// supported ("meta.title" → object with `meta.title` set, other meta
|
|
549
|
+
// keys dropped). Used by the snapshot fast paths in list/live so a
|
|
550
|
+
// caller asking for narrow fields still gets narrow data from the
|
|
551
|
+
// snapshot — saves a re-fetch and keeps memory/bundles smaller.
|
|
552
|
+
function pickFields(entity, fields) {
|
|
553
|
+
const out = {}
|
|
554
|
+
for (const field of fields) {
|
|
555
|
+
const parts = field.split('.')
|
|
556
|
+
let src = entity, dst = out
|
|
557
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
558
|
+
const part = parts[i]
|
|
559
|
+
if (src == null || typeof src !== 'object') { src = undefined; break }
|
|
560
|
+
src = src[part]
|
|
561
|
+
if (dst[part] == null || typeof dst[part] !== 'object') dst[part] = {}
|
|
562
|
+
dst = dst[part]
|
|
563
|
+
}
|
|
564
|
+
if (src !== undefined) {
|
|
565
|
+
const last = parts[parts.length - 1]
|
|
566
|
+
if (src != null && typeof src === 'object' && last in src) {
|
|
567
|
+
dst[last] = src[last]
|
|
568
|
+
} else if (parts.length === 1 && entity[last] !== undefined) {
|
|
569
|
+
out[last] = entity[last]
|
|
570
|
+
}
|
|
571
|
+
}
|
|
268
572
|
}
|
|
573
|
+
return out
|
|
269
574
|
}
|