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 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, { token })` returns a per-endpoint client. The endpoint name matches a key in your `api.endpoints` config on the server.
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 adapters in [**Recipes**](#recipes--composing-real-time-and-search) use.
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 React-style frameworks (the callback can replace state). If you need per-event deltas — animated reveals, audit logs, derived counters — use `watch()` directly.
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 `docs.live()`. The framework adapters are ~5 lines each they just give the SDK a callback and call dispose on unmount.
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
- The shared module used by every variant below — wires the client once:
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
- ```js
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
- #### Vue 3 (composable, Composition API)
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
- // useLiveEntities.js
495
- import { ref, onMounted, onUnmounted } from 'vue'
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
- ```vue
508
- <!-- ArticleList.vue -->
509
- <script setup>
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
- ```svelte
540
- <!-- ArticleList.svelte -->
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.0",
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, { token } = {}) {
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
- * Body-based query. Send everything sift accepts —
20
- * $and / $or / $regex, projections, sorts. Returns the standard
21
- * envelope: { items, page, limit, total, totalPages, hasNext, hasPrev }.
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
- return jsonOrThrow(res, queryUrl)
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
- const env = await list({ filter, sort, fields, limit, skip })
168
- if (disposed || ac.signal.aborted) return
169
- items = env.items
170
- onChange(items)
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
  }