mikser-io-sdk-api 2.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Almero Digital Marketing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,636 @@
1
+ # mikser-io-sdk-api
2
+
3
+ Client SDK for querying a [mikser-io](https://github.com/almero-digital-marketing/mikser-io) server's `api` plugin from the browser or Node — list / query / paginate / project the document catalog, subscribe to live changes, and trigger renders.
4
+
5
+ Mikser keeps content as plain files. This SDK lets the frontend ask for exactly the slice it needs over HTTP — Mongo-style filter operators, sort, projection, pagination — without shipping the whole catalog and filtering in JS.
6
+
7
+ For semantic search against the `vector` plugin, install [mikser-io-sdk-vector](https://github.com/almero-digital-marketing/mikser-io-sdk-vector) — it ships as a separate package.
8
+
9
+ Zero dependencies. Runs anywhere `fetch` is available (modern browsers, Node 18+, Deno, Bun, Workers).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install mikser-io-sdk-api
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```js
20
+ import { createClient } from 'mikser-io-sdk-api'
21
+
22
+ const mikser = createClient({ baseUrl: 'http://localhost:3001' })
23
+ const docs = mikser.entities('public')
24
+
25
+ const { items, total, hasNext } = await docs.list({
26
+ filter: {
27
+ 'meta.published': true,
28
+ 'meta.price': { $gt: 20, $lt: 80 },
29
+ },
30
+ sort: { 'meta.date': -1 },
31
+ fields: ['id', 'meta.title', 'meta.price'],
32
+ limit: 10,
33
+ })
34
+ ```
35
+
36
+ ## Wiring it together
37
+
38
+ The contract has three pieces: server config, an endpoint URL, an SDK call. Each maps 1:1 — if you can read the server's `api.endpoints` block, you know exactly what the SDK can do.
39
+
40
+ **On the server** — `mikser.config.js` declares named endpoints. Each endpoint becomes a URL path; its options control what's visible, what operations are allowed, and whether a token is required.
41
+
42
+ ```js
43
+ // mikser.config.js — on the server
44
+ export default {
45
+ plugins: ['documents', 'layouts', 'render-hbs', 'api'],
46
+
47
+ api: {
48
+ endpoints: {
49
+ // Public reader — anyone can list published docs, no token.
50
+ // `subscribe` is opt-in for public endpoints (each connection
51
+ // holds resources), so list it explicitly here.
52
+ public: {
53
+ query: e => e.type === 'document' && e.meta?.published,
54
+ operations: ['list', 'subscribe'],
55
+ },
56
+
57
+ // Editor — token-gated, full surface. Defaults already
58
+ // include list/update/delete/render/subscribe when token
59
+ // is set, so the operations array can be omitted.
60
+ editor: {
61
+ token: process.env.EDITOR_TOKEN,
62
+ },
63
+ },
64
+ },
65
+ }
66
+ ```
67
+
68
+ **On the client** — one `createClient` per app, then one `entities(name)` per endpoint:
69
+
70
+ ```js
71
+ import { createClient } from 'mikser-io-sdk-api'
72
+
73
+ const mikser = createClient({ baseUrl: 'https://cms.example.com' })
74
+
75
+ // Reads public docs only (server's `query` scope hides drafts)
76
+ const docs = mikser.entities('public')
77
+
78
+ // Token-gated — can write + render, in addition to read + subscribe
79
+ const editor = mikser.entities('editor', { token: process.env.EDITOR_TOKEN })
80
+ ```
81
+
82
+ The mapping is direct:
83
+
84
+ | Server (`mikser.config.js`) | Client (SDK) |
85
+ |---|---|
86
+ | `api.endpoints.public` | `mikser.entities('public')` |
87
+ | `api.endpoints.editor.token` | `mikser.entities('editor', { token })` |
88
+ | `query: e => …` | invisible — applied server-side as outer scope |
89
+ | `operations: ['list']` | only `.list()` / `.query()` / `.urlFor()` / `.pages()` succeed |
90
+ | `operations: [..., 'subscribe']` | `.watch()` works |
91
+ | `operations: [..., 'update', 'delete']` | `.update()` / `.delete()` work |
92
+ | `operations: [..., 'render']` | `.render()` works |
93
+
94
+ Operations outside the endpoint's allowlist return `403`; missing or wrong tokens return `401` (both thrown as `MikserError`). The server is always the boundary — the SDK is just the typed shape of what the boundary lets through.
95
+
96
+ ## Entities
97
+
98
+ `mikser.entities(endpointName, { token })` returns a per-endpoint client. The endpoint name matches a key in your `api.endpoints` config on the server.
99
+
100
+ ### `list(query)` — body-based
101
+
102
+ POSTs `/api/<endpoint>/entities/query` so any sift filter works (incl. `$and`, `$or`, regex).
103
+
104
+ ```js
105
+ const { items } = await docs.list({
106
+ filter: {
107
+ $or: [
108
+ { 'meta.tags': { $in: ['product'] } },
109
+ { type: 'category' },
110
+ ],
111
+ 'meta.date': { $gte: '2025-01-01' },
112
+ },
113
+ sort: { 'meta.date': -1, 'meta.title': 1 },
114
+ fields: ['id', 'meta.title', 'meta.summary'],
115
+ page: 1,
116
+ limit: 20,
117
+ })
118
+ ```
119
+
120
+ Response envelope: `{ items, page, limit, total, totalPages, hasNext, hasPrev }`.
121
+
122
+ Use dotted-path keys for nested fields (`'meta.price': { $gt: 20 }`). Nested object literals (`{ meta: { price: { $gt: 20 } } }`) are interpreted as deep-equality — same gotcha as Mongo.
123
+
124
+ ### `urlFor(query)` — GET-form URL
125
+
126
+ Build a URL for the GET form of the same query. Useful when the response should be CDN-cacheable, or you want a sharable link.
127
+
128
+ ```js
129
+ const url = docs.urlFor({
130
+ filter: { 'meta.published': true, 'meta.price': { $gt: 20 } },
131
+ sort: { 'meta.date': -1 },
132
+ limit: 10,
133
+ })
134
+ // http://localhost:3001/api/public/entities?meta.published=true&meta.price.$gt=20&sort=-meta.date&limit=10
135
+ ```
136
+
137
+ ### `pages(query)` — async iterator
138
+
139
+ ```js
140
+ for await (const env of docs.pages({ filter: { type: 'document' }, limit: 50 })) {
141
+ for (const item of env.items) {
142
+ process(item)
143
+ }
144
+ }
145
+ ```
146
+
147
+ ### `watch(query, { signal })` — live subscription via SSE
148
+
149
+ Open a Server-Sent Events stream and yield events as matching entities change. The lowest-level real-time primitive — useful when you want raw events.
150
+
151
+ ```js
152
+ const ac = new AbortController()
153
+
154
+ // Initial state
155
+ const { items } = await docs.list({ filter: { 'meta.published': true } })
156
+ items.forEach(addToView)
157
+
158
+ // Forward updates
159
+ for await (const event of docs.watch(
160
+ { filter: { 'meta.published': true } },
161
+ { signal: ac.signal },
162
+ )) {
163
+ switch (event.type) {
164
+ case 'create': addToView(event.entity); break
165
+ case 'update': updateInView(event.entity); break
166
+ case 'delete': removeFromView(event.id); break
167
+ }
168
+ }
169
+
170
+ // Call ac.abort() to close the stream.
171
+ ```
172
+
173
+ Events fire on **every** server process cycle — both file-watcher–driven changes (the editor saving a file, decap committing) and programmatic writes (`update()` / `delete()` via this SDK). No second mechanism to wire up.
174
+
175
+ Requires the endpoint to include `subscribe` in its `operations`. Public endpoints don't get it by default (each connection holds resources); token-gated endpoints do.
176
+
177
+ For framework integration, prefer `live()` below — it handles the list+watch composition with race-safe cleanup.
178
+
179
+ ### `live(filter, onChange, options)` — list + watch in one callback
180
+
181
+ The higher-level real-time primitive. Calls `onChange(items)` with the initial snapshot, then again with the patched array on every create/update/delete event. Returns a dispose function.
182
+
183
+ ```js
184
+ const dispose = docs.live(
185
+ { 'meta.published': true, type: 'document' },
186
+ items => setItems(items),
187
+ {
188
+ sort: { 'meta.date': -1 },
189
+ fields: ['id', 'meta.title', 'meta.date', 'meta.summary'],
190
+ limit: 20,
191
+ signal: abortController?.signal, // optional external abort
192
+ onError: err => console.error(err), // optional error sink
193
+ },
194
+ )
195
+
196
+ // Later:
197
+ dispose()
198
+ ```
199
+
200
+ Equivalent to:
201
+
202
+ ```js
203
+ // 1. await list({ filter, sort, fields, limit, skip })
204
+ // 2. onChange(items)
205
+ // 3. for await (event of watch({ filter })) patch + onChange
206
+ // 4. abort on dispose
207
+ ```
208
+
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.
210
+
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.
212
+
213
+ ### `update(payload)` / `delete(payload)` — writes
214
+
215
+ Requires a token-gated endpoint with `operations: ['update', 'delete', ...]`.
216
+
217
+ ```js
218
+ const admin = mikser.entities('admin', { token: process.env.ADMIN_TOKEN })
219
+
220
+ await admin.update({
221
+ collection: 'documents',
222
+ relativePath: 'blog/new-post.md',
223
+ content: '---\ntitle: Hello\n---\n\nHello world.',
224
+ })
225
+
226
+ await admin.delete({
227
+ collection: 'documents',
228
+ relativePath: 'blog/old-post.md',
229
+ })
230
+ ```
231
+
232
+ ### `render(entity, options)` — render in memory
233
+
234
+ ```js
235
+ const html = await admin.render(
236
+ { id: '/documents/blog/preview.md', collection: 'documents', type: 'document',
237
+ format: 'md', meta: { title: 'Preview', layout: 'post' }, content: '# Preview' },
238
+ { save: false, catalog: false },
239
+ )
240
+ ```
241
+
242
+ Return shape follows the response `content-type`:
243
+ - `application/json` → parsed JSON
244
+ - `text/*` → `string`
245
+ - anything else (`application/pdf`, images, …) → `ArrayBuffer`
246
+
247
+ ## Recipes — composing real-time and search
248
+
249
+ 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.
250
+
251
+ ### Live article index for a marketing site
252
+
253
+ The home page shows the latest published articles. When an editor publishes a new one through Decap (or anything that writes to the documents folder), it should appear without a refresh; edits update in place; deletions disappear. The same `filter` drives both the initial fetch and the live subscription, so the two stay in sync.
254
+
255
+ ```js
256
+ import { createClient } from 'mikser-io-sdk-api'
257
+
258
+ const docs = createClient({ baseUrl: 'https://cms.example.com' })
259
+ .entities('public')
260
+
261
+ // One filter expression, used for both list() and watch() — keeps the
262
+ // "what counts as visible" decision in one place.
263
+ const filter = {
264
+ type: 'document',
265
+ 'meta.collection': 'articles',
266
+ 'meta.published': true,
267
+ }
268
+
269
+ const list = document.getElementById('article-list')
270
+ const byId = new Map() // id → DOM element
271
+
272
+ function render(entity) {
273
+ const el = document.createElement('article')
274
+ el.dataset.id = entity.id
275
+ el.dataset.date = entity.meta.date
276
+ el.innerHTML = `
277
+ <h2>${entity.meta.title}</h2>
278
+ <time>${entity.meta.date}</time>
279
+ <p>${entity.meta.summary ?? ''}</p>
280
+ `
281
+ return el
282
+ }
283
+
284
+ function insertSortedByDate(el) {
285
+ // New items go to the top of the list, preserving date-desc order.
286
+ const next = [...list.children].find(c => c.dataset.date < el.dataset.date)
287
+ if (next) list.insertBefore(el, next); else list.appendChild(el)
288
+ }
289
+
290
+ // 1. Initial snapshot — render what's already published.
291
+ const { items } = await docs.list({
292
+ filter,
293
+ sort: { 'meta.date': -1 },
294
+ fields: ['id', 'meta.title', 'meta.date', 'meta.summary'],
295
+ limit: 20,
296
+ })
297
+ for (const item of items) {
298
+ const el = render(item)
299
+ byId.set(item.id, el)
300
+ insertSortedByDate(el)
301
+ }
302
+
303
+ // 2. Forward subscription — patch the DOM as content changes.
304
+ const ac = new AbortController()
305
+ addEventListener('beforeunload', () => ac.abort())
306
+
307
+ for await (const event of docs.watch({ filter }, { signal: ac.signal })) {
308
+ switch (event.type) {
309
+ case 'create': {
310
+ const el = render(event.entity)
311
+ byId.set(event.id, el)
312
+ insertSortedByDate(el)
313
+ break
314
+ }
315
+ case 'update': {
316
+ const old = byId.get(event.id)
317
+ const el = render(event.entity)
318
+ byId.set(event.id, el)
319
+ if (old) old.replaceWith(el); else insertSortedByDate(el)
320
+ break
321
+ }
322
+ case 'delete': {
323
+ byId.get(event.id)?.remove()
324
+ byId.delete(event.id)
325
+ break
326
+ }
327
+ // 'init' fires once when the subscription opens — no-op here.
328
+ // 'heartbeat' fires periodically to keep the connection alive.
329
+ }
330
+ }
331
+ ```
332
+
333
+ Notice the same filter scope on both calls. The server's endpoint scope (`type === 'document' && meta?.published`) ANDs with it on both sides, so unpublishing a doc in Decap fires a `delete` event from this filter's perspective even though the file still exists — the entity dropped out of the visible set.
334
+
335
+ ### Single-document live preview
336
+
337
+ An editor previews a `.md` they're writing; the preview pane should re-render whenever the file is saved.
338
+
339
+ ```js
340
+ const docs = createClient({ baseUrl: 'https://cms.example.com' })
341
+ .entities('public')
342
+
343
+ const previewedId = '/documents/en/draft.md'
344
+ const pane = document.getElementById('preview')
345
+
346
+ async function refresh() {
347
+ const { items: [entity] } = await docs.list({
348
+ filter: { id: previewedId },
349
+ limit: 1,
350
+ })
351
+ pane.innerHTML = entity?.content ?? '<em>not found</em>'
352
+ }
353
+
354
+ await refresh()
355
+
356
+ // Subscribe only to events touching this one entity — the filter is
357
+ // just an equality match on `id`.
358
+ const ac = new AbortController()
359
+ for await (const event of docs.watch(
360
+ { filter: { id: previewedId } },
361
+ { signal: ac.signal },
362
+ )) {
363
+ if (event.type === 'update') await refresh()
364
+ if (event.type === 'delete') pane.innerHTML = '<em>document deleted</em>'
365
+ }
366
+ ```
367
+
368
+ The narrow filter (`{ id: previewedId }`) means the subscription fires only for this exact entity. Mikser's server still walks the full journal per cycle, but for this client only one match dispatches.
369
+
370
+ ### Search + enrich + live (mixing both SDKs)
371
+
372
+ A search-as-you-type UI. The user types a query; the **vector** SDK does semantic search and returns ranked hits; the **api** SDK keeps the list of currently displayed docs in sync if any of them changes underneath.
373
+
374
+ ```js
375
+ import { createClient as createApiClient } from 'mikser-io-sdk-api'
376
+ import { createClient as createVectorClient } from 'mikser-io-sdk-vector'
377
+
378
+ const baseUrl = 'https://cms.example.com'
379
+ const docs = createApiClient( { baseUrl }).entities('public')
380
+ const search = createVectorClient({ baseUrl }).vector('documents')
381
+
382
+ const results = new Map() // id → result row { id, distance, title, summary }
383
+ const resultsEl = document.getElementById('search-results')
384
+
385
+ function rerender() {
386
+ resultsEl.innerHTML = ''
387
+ for (const r of results.values()) {
388
+ const el = document.createElement('li')
389
+ el.innerHTML = `<strong>${r.title}</strong><br><small>${r.distance.toFixed(3)}</small><p>${r.summary ?? ''}</p>`
390
+ el.dataset.id = r.id
391
+ resultsEl.appendChild(el)
392
+ }
393
+ }
394
+
395
+ async function runSearch(text) {
396
+ results.clear()
397
+ // `data` is whatever your server's vector.stores[name].map() returned —
398
+ // typically { title, summary, ... } — so render directly without a
399
+ // second fetch.
400
+ const hits = await search.findSimilar(text, { limit: 10 })
401
+ for (const { id, distance, data } of hits.results) {
402
+ results.set(id, {
403
+ id, distance,
404
+ title: data?.title ?? id,
405
+ summary: data?.summary,
406
+ })
407
+ }
408
+ rerender()
409
+ }
410
+
411
+ // Background subscription — refresh result rows whose entities change.
412
+ // Vector results don't re-rank on the fly, but we DO want the displayed
413
+ // metadata (title, summary) to stay fresh, and we want deleted docs to
414
+ // drop out.
415
+ const ac = new AbortController()
416
+ ;(async () => {
417
+ for await (const event of docs.watch(
418
+ { filter: { type: 'document' } },
419
+ { signal: ac.signal },
420
+ )) {
421
+ if (event.type === 'delete' && results.has(event.id)) {
422
+ results.delete(event.id)
423
+ rerender()
424
+ }
425
+ if (event.type === 'update' && results.has(event.id)) {
426
+ const r = results.get(event.id)
427
+ results.set(event.id, {
428
+ ...r,
429
+ title: event.entity.meta?.title ?? r.title,
430
+ summary: event.entity.meta?.summary ?? r.summary,
431
+ })
432
+ rerender()
433
+ }
434
+ }
435
+ })()
436
+
437
+ document.getElementById('search-input').addEventListener('input', e => {
438
+ if (e.target.value.length >= 3) runSearch(e.target.value)
439
+ })
440
+ ```
441
+
442
+ Two SDKs, one mental model, one server. The vector store gives you ranked semantic hits; the api watch keeps them honest about their current content.
443
+
444
+ ### Framework integration
445
+
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.
447
+
448
+ The shared module — used by every variant below — wires the client once:
449
+
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
+ ```
490
+
491
+ #### Vue 3 (composable, Composition API)
492
+
493
+ ```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
+ ```
506
+
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 },
514
+ { sort: { 'meta.date': -1 }, limit: 20 },
515
+ )
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
+
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>
578
+ ```
579
+
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
+ ## Configure
583
+
584
+ ```js
585
+ const mikser = createClient({
586
+ baseUrl: 'https://cms.example.com',
587
+ basePath: '/api', // default — must match api.base on the server
588
+ headers: { 'x-trace-id': '...' }, // attached to every request
589
+ fetch: myFetchImpl, // override (default: globalThis.fetch)
590
+ })
591
+ ```
592
+
593
+ ## Errors
594
+
595
+ Non-2xx responses throw `MikserError`:
596
+
597
+ ```js
598
+ import { MikserError } from 'mikser-io-sdk-api'
599
+
600
+ try {
601
+ await docs.list({ filter: { ... } })
602
+ } catch (err) {
603
+ if (err instanceof MikserError) {
604
+ console.error(err.status, err.body?.error)
605
+ }
606
+ }
607
+ ```
608
+
609
+ ## TypeScript
610
+
611
+ Full type declarations ship with the package — including a `Filter` type that covers the sift operator subset.
612
+
613
+ ```ts
614
+ import type { ListEnvelope } from 'mikser-io-sdk-api'
615
+
616
+ interface Doc { id: string; meta: { title: string; price?: number } }
617
+
618
+ const env: ListEnvelope<Doc> = await mikser.entities('public').list<Doc>({ ... })
619
+ ```
620
+
621
+ ## Using both SDKs together
622
+
623
+ If a project needs both document queries and semantic search, install both packages and alias the factories:
624
+
625
+ ```js
626
+ import { createClient as createApiClient } from 'mikser-io-sdk-api'
627
+ import { createClient as createVectorClient } from 'mikser-io-sdk-vector'
628
+
629
+ const baseUrl = 'http://localhost:3001'
630
+ const docs = createApiClient({ baseUrl }).entities('public')
631
+ const search = createVectorClient({ baseUrl }).vector('documents')
632
+ ```
633
+
634
+ ## License
635
+
636
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,151 @@
1
+ // Type declarations for mikser-io-sdk-api.
2
+ // Intentionally permissive on the entity / data shape — every project's
3
+ // content is different, so callers narrow these with their own types.
4
+
5
+ export interface ClientOptions {
6
+ /** Origin of the mikser server, e.g. https://cms.example.com */
7
+ baseUrl: string
8
+ /** api plugin mount path (default '/api'). */
9
+ basePath?: string
10
+ /** Override fetch (default: globalThis.fetch). */
11
+ fetch?: typeof fetch
12
+ /** Headers attached to every request. */
13
+ headers?: Record<string, string>
14
+ }
15
+
16
+ export interface EntityOptions {
17
+ /** Bearer token sent on every request to this endpoint. */
18
+ token?: string
19
+ }
20
+
21
+ /**
22
+ * A subset of the Mongo query language as understood by sift on the
23
+ * server. Use dotted-path keys (`'meta.price'`) for nested fields —
24
+ * nested object literals are interpreted as deep-equality matches.
25
+ */
26
+ export type Filter = Record<string, FilterValue> & {
27
+ $and?: Filter[]
28
+ $or?: Filter[]
29
+ $not?: Filter
30
+ }
31
+
32
+ export type FilterValue =
33
+ | string | number | boolean | null
34
+ | {
35
+ $eq?: unknown
36
+ $ne?: unknown
37
+ $gt?: unknown
38
+ $gte?: unknown
39
+ $lt?: unknown
40
+ $lte?: unknown
41
+ $in?: unknown[]
42
+ $nin?: unknown[]
43
+ $exists?: boolean
44
+ $regex?: string
45
+ $not?: FilterValue
46
+ }
47
+
48
+ export interface ListQuery {
49
+ filter?: Filter
50
+ sort?: Record<string, 1 | -1>
51
+ fields?: string[]
52
+ page?: number
53
+ skip?: number
54
+ limit?: number
55
+ }
56
+
57
+ export interface ListEnvelope<T = unknown> {
58
+ items: T[]
59
+ page: number
60
+ limit: number
61
+ total: number
62
+ totalPages: number
63
+ hasNext: boolean
64
+ hasPrev: boolean
65
+ }
66
+
67
+ export interface UpdatePayload {
68
+ collection: string
69
+ relativePath: string
70
+ content?: string
71
+ }
72
+
73
+ export interface DeletePayload {
74
+ collection: string
75
+ relativePath: string
76
+ }
77
+
78
+ export interface RenderOptions {
79
+ save?: boolean
80
+ catalog?: boolean
81
+ [key: string]: unknown
82
+ }
83
+
84
+ export type WatchEvent<T = unknown> =
85
+ | { type: 'init'; subscriptionId: string; endpoint: string }
86
+ | { type: 'create'; id: string; entity: T }
87
+ | { type: 'update'; id: string; entity: T }
88
+ | { type: 'delete'; id: string }
89
+ | { type: 'heartbeat' }
90
+
91
+ export interface WatchOptions {
92
+ /** Abort the SSE stream. */
93
+ signal?: AbortSignal
94
+ }
95
+
96
+ export interface LiveOptions {
97
+ /** Sort applied to the initial list(); not re-applied to live updates. */
98
+ sort?: Record<string, 1 | -1>
99
+ /** Field projection for the initial list(). */
100
+ fields?: string[]
101
+ /** Page size for the initial list(). */
102
+ limit?: number
103
+ /** Skip for the initial list(). */
104
+ skip?: number
105
+ /** External AbortSignal — calling abort() stops the live view. */
106
+ signal?: AbortSignal
107
+ /** Error sink. Defaults to console.error. */
108
+ onError?: (err: unknown) => void
109
+ }
110
+
111
+ export interface EntitiesClient {
112
+ /** POST /entities/query — body-based, supports any sift filter. */
113
+ list<T = unknown>(query?: ListQuery): Promise<ListEnvelope<T>>
114
+ /** Build the GET-form URL — CDN-cacheable, sharable. */
115
+ urlFor(query?: ListQuery): string
116
+ /** Iterate result pages — yields each envelope until hasNext is false. */
117
+ pages<T = unknown>(query?: ListQuery): AsyncGenerator<ListEnvelope<T>>
118
+ /**
119
+ * Open an SSE stream and yield events as matching entities change.
120
+ * Compose with list() for initial state, then watch() for updates.
121
+ */
122
+ watch<T = unknown>(query?: ListQuery, options?: WatchOptions): AsyncGenerator<WatchEvent<T>>
123
+ /**
124
+ * list-and-watch composed: calls onChange(items) with the initial
125
+ * result, then again on every change. Returns a dispose function.
126
+ * The race-safe building block for framework-side hooks.
127
+ */
128
+ live<T = unknown>(
129
+ filter: Filter,
130
+ onChange: (items: T[]) => void,
131
+ options?: LiveOptions,
132
+ ): () => void
133
+ /** PUT — upsert a file in a collection folder. */
134
+ update(payload: UpdatePayload): Promise<{ ok: true }>
135
+ /** DELETE — remove a file from a collection folder. */
136
+ delete(payload: DeletePayload): Promise<{ ok: true }>
137
+ /** POST /render — render an entity in memory; return shape varies by output content-type. */
138
+ render(entity: Record<string, unknown>, options?: RenderOptions): Promise<unknown>
139
+ }
140
+
141
+ export interface MikserClient {
142
+ entities(name: string, options?: EntityOptions): EntitiesClient
143
+ }
144
+
145
+ export declare function createClient(options: ClientOptions): MikserClient
146
+
147
+ export declare class MikserError extends Error {
148
+ name: 'MikserError'
149
+ status: number
150
+ body: { error?: string } | undefined
151
+ }
package/index.js ADDED
@@ -0,0 +1,62 @@
1
+ // mikser-io-sdk-api
2
+ //
3
+ // A tiny client wrapper over the mikser-io `api` plugin — list/query
4
+ // the document catalog from the browser or Node 18+. Uses the global
5
+ // `fetch`. Zero dependencies.
6
+ //
7
+ // For semantic search against the `vector` plugin, install
8
+ // mikser-io-sdk-vector — it ships as a separate package.
9
+ //
10
+ // Usage:
11
+ //
12
+ // import { createClient } from 'mikser-io-sdk-api'
13
+ //
14
+ // const mikser = createClient({ baseUrl: 'http://localhost:3001' })
15
+ // const docs = mikser.entities('public')
16
+ //
17
+ // const { items } = await docs.list({
18
+ // filter: { 'meta.published': true, 'meta.price': { $gt: 20 } },
19
+ // sort: { 'meta.date': -1 },
20
+ // fields: ['id', 'meta.title'],
21
+ // limit: 10,
22
+ // })
23
+ //
24
+ // Source layout (kept thin so the entry point stays a quick read):
25
+ // src/error.js — MikserError class
26
+ // src/http.js — fetch helpers (bearer, jsonOrThrow)
27
+ // src/url.js — URL building (joinUrl, sortToParam, filterToParams)
28
+ // src/sse.js — SSE event parser
29
+ // src/entities.js — per-endpoint entities client (list / watch / live / ...)
30
+ import { MikserError } from './src/error.js'
31
+ import { createEntitiesClient } from './src/entities.js'
32
+
33
+ /**
34
+ * @param {Object} options
35
+ * @param {string} options.baseUrl Origin of the mikser server (e.g. https://cms.example.com)
36
+ * @param {string} [options.basePath] api plugin mount path; default '/api'
37
+ * @param {typeof fetch} [options.fetch] override the fetch implementation (default: globalThis.fetch)
38
+ * @param {Record<string,string>} [options.headers] headers attached to every request
39
+ */
40
+ export function createClient({
41
+ baseUrl,
42
+ basePath = '/api',
43
+ fetch: fetchImpl,
44
+ headers: defaultHeaders = {},
45
+ } = {}) {
46
+ if (!baseUrl) throw new Error('createClient: baseUrl is required')
47
+ const doFetch = fetchImpl ?? globalThis.fetch
48
+ if (!doFetch) {
49
+ throw new Error('createClient: no fetch available — pass { fetch } or run on Node 18+ / a modern browser')
50
+ }
51
+
52
+ const entities = createEntitiesClient({
53
+ baseUrl,
54
+ basePath,
55
+ fetch: doFetch,
56
+ headers: defaultHeaders,
57
+ })
58
+
59
+ return { entities }
60
+ }
61
+
62
+ export { MikserError }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "mikser-io-sdk-api",
3
+ "version": "2.3.0",
4
+ "description": "Client SDK for mikser-io's api plugin — query the document catalog from the browser or Node",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts",
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {},
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/almero-digital-marketing/mikser-io-sdk-api.git"
25
+ },
26
+ "author": "",
27
+ "license": "MIT",
28
+ "bugs": {
29
+ "url": "https://github.com/almero-digital-marketing/mikser-io-sdk-api/issues"
30
+ },
31
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io-sdk-api#readme",
32
+ "keywords": [
33
+ "mikser",
34
+ "mikser-io",
35
+ "sdk",
36
+ "client",
37
+ "query"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }
@@ -0,0 +1,269 @@
1
+ // Per-endpoint entities client. Returns a function `entities(name, opts)`
2
+ // closed over the createClient-level config (baseUrl, basePath, fetch,
3
+ // default headers) — kept here so index.js stays focused on the
4
+ // top-level createClient factory.
5
+ import { MikserError } from './error.js'
6
+ import { bearer, jsonOrThrow } from './http.js'
7
+ import { joinUrl, sortToParam, filterToParams } from './url.js'
8
+ import { parseSseEvent } from './sse.js'
9
+
10
+ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, headers: defaultHeaders }) {
11
+ return function entities(name, { token } = {}) {
12
+ const endpointBase = `${basePath}/${name}`
13
+ const queryUrl = joinUrl(baseUrl, `${endpointBase}/entities/query`)
14
+ const listUrl = joinUrl(baseUrl, `${endpointBase}/entities`)
15
+ const subscribeUrl = joinUrl(baseUrl, `${endpointBase}/entities/subscribe`)
16
+ const renderUrl = joinUrl(baseUrl, `${endpointBase}/render`)
17
+
18
+ /**
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 }.
22
+ */
23
+ async function list(query = {}) {
24
+ const res = await doFetch(queryUrl, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'content-type': 'application/json',
28
+ ...defaultHeaders,
29
+ ...bearer(token),
30
+ },
31
+ body: JSON.stringify(query),
32
+ })
33
+ return jsonOrThrow(res, queryUrl)
34
+ }
35
+
36
+ /**
37
+ * Build a URL for the GET form of the same query — useful when
38
+ * the response should be CDN-cacheable, or when the caller wants
39
+ * a sharable link. Operators map to `.$op` URL-param suffixes.
40
+ */
41
+ function urlFor(query = {}) {
42
+ const url = new URL(listUrl)
43
+ const { filter, sort, fields, page, limit, skip } = query
44
+ if (page != null) url.searchParams.set('page', String(page))
45
+ if (limit != null) url.searchParams.set('limit', String(limit))
46
+ if (skip != null) url.searchParams.set('skip', String(skip))
47
+ if (sort) url.searchParams.set('sort', sortToParam(sort))
48
+ if (fields) url.searchParams.set('fields', fields.join(','))
49
+ if (filter) filterToParams(filter, url.searchParams)
50
+ return url.toString()
51
+ }
52
+
53
+ /**
54
+ * Iterate result pages without manual page bookkeeping. Yields
55
+ * each response envelope until `hasNext` is false.
56
+ */
57
+ async function* pages(query = {}) {
58
+ let page = query.page ?? 1
59
+ while (true) {
60
+ const env = await list({ ...query, page })
61
+ yield env
62
+ if (!env.hasNext) return
63
+ page = env.page + 1
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Subscribe to changes — opens an SSE stream and yields events
69
+ * for each matching entity change (CREATE / UPDATE / DELETE).
70
+ * Composable with list(): call list() once for the initial state,
71
+ * then watch() for forward updates.
72
+ *
73
+ * Yielded events:
74
+ * { type: 'init', subscriptionId, endpoint }
75
+ * { type: 'create', id, entity }
76
+ * { type: 'update', id, entity }
77
+ * { type: 'delete', id }
78
+ * { type: 'heartbeat' }
79
+ *
80
+ * Pass { signal } from an AbortController to close the stream.
81
+ */
82
+ async function* watch(query = {}, { signal } = {}) {
83
+ const url = new URL(subscribeUrl)
84
+ if (query.filter) filterToParams(query.filter, url.searchParams)
85
+
86
+ const res = await doFetch(url.toString(), {
87
+ method: 'GET',
88
+ headers: {
89
+ accept: 'text/event-stream',
90
+ ...defaultHeaders,
91
+ ...bearer(token),
92
+ },
93
+ signal,
94
+ })
95
+ if (!res.ok) {
96
+ let body
97
+ try { body = await res.json() } catch {}
98
+ throw new MikserError(res.status, res.statusText, body, url.toString())
99
+ }
100
+ if (!res.body) throw new Error('watch: response has no body — server may not support streaming')
101
+
102
+ // Fetch internally creates a body-stream cancel promise when
103
+ // the abort signal fires. Nothing in user code awaits it, so
104
+ // it surfaces as an unhandled rejection. Pre-attach a no-op
105
+ // catch via a body.cancel() the moment we see the abort —
106
+ // makes that internal promise handled.
107
+ if (signal) {
108
+ signal.addEventListener('abort', () => {
109
+ try { res.body.cancel().catch(() => {}) } catch {}
110
+ }, { once: true })
111
+ }
112
+
113
+ const reader = res.body.getReader()
114
+ const decoder = new TextDecoder()
115
+ let buffer = ''
116
+ try {
117
+ while (true) {
118
+ const { done, value } = await reader.read()
119
+ if (done) return
120
+ buffer += decoder.decode(value, { stream: true })
121
+ let sep
122
+ while ((sep = buffer.indexOf('\n\n')) >= 0) {
123
+ const raw = buffer.slice(0, sep)
124
+ buffer = buffer.slice(sep + 2)
125
+ const event = parseSseEvent(raw)
126
+ if (event) yield event
127
+ }
128
+ }
129
+ } finally {
130
+ try { reader.cancel().catch(() => {}) } catch {}
131
+ }
132
+ }
133
+
134
+ /**
135
+ * live() — list-and-watch composed into one callback-driven view.
136
+ * Calls onChange(items) with the initial result, then again with
137
+ * the patched array on every create / update / delete event.
138
+ * Returns a dispose function — call it to stop the subscription.
139
+ *
140
+ * Equivalent to:
141
+ * 1. await list({ filter, sort, fields, limit, skip })
142
+ * 2. onChange(items)
143
+ * 3. for await (event of watch({ filter })) patch + onChange
144
+ * 4. abort on dispose
145
+ *
146
+ * but with race-safe cleanup, no `mounted` flag in caller code,
147
+ * and unified error routing via onError.
148
+ */
149
+ function live(filter, onChange, options = {}) {
150
+ const {
151
+ sort, fields, limit, skip,
152
+ signal: externalSignal,
153
+ onError = (err) => console.error('mikser-io-sdk-api live error:', err),
154
+ } = options
155
+
156
+ const ac = new AbortController()
157
+ if (externalSignal) {
158
+ if (externalSignal.aborted) ac.abort()
159
+ else externalSignal.addEventListener('abort', () => ac.abort(), { once: true })
160
+ }
161
+
162
+ let items = []
163
+ let disposed = false
164
+
165
+ const loop = (async () => {
166
+ 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)
171
+
172
+ for await (const event of watch({ filter }, { signal: ac.signal })) {
173
+ if (disposed) return
174
+ switch (event.type) {
175
+ case 'create':
176
+ items = [...items, event.entity]
177
+ onChange(items)
178
+ break
179
+ case 'update':
180
+ items = items.map(i => i.id === event.id ? event.entity : i)
181
+ onChange(items)
182
+ break
183
+ case 'delete':
184
+ items = items.filter(i => i.id !== event.id)
185
+ onChange(items)
186
+ break
187
+ // 'init' / 'heartbeat' — no-op for the live view
188
+ }
189
+ }
190
+ } catch (err) {
191
+ if (disposed || ac.signal.aborted) return
192
+ if (err?.name === 'AbortError') return
193
+ try { onError(err) } catch { /* swallow handler errors */ }
194
+ }
195
+ })()
196
+ // Safety net: any error escaping the IIFE (rare, e.g. a late
197
+ // AbortError from a fetch unwind that beats the disposed
198
+ // check) gets swallowed silently rather than surfacing as an
199
+ // unhandled rejection.
200
+ loop.catch(() => {})
201
+
202
+ return function dispose() {
203
+ disposed = true
204
+ try { ac.abort() } catch { /* abort never normally throws, but stay defensive */ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * PUT — upsert content into a collection folder. The watcher
210
+ * picks it up and runs the normal pipeline.
211
+ */
212
+ async function update({ collection, relativePath, content = '' }) {
213
+ const res = await doFetch(listUrl, {
214
+ method: 'PUT',
215
+ headers: {
216
+ 'content-type': 'application/json',
217
+ ...defaultHeaders,
218
+ ...bearer(token),
219
+ },
220
+ body: JSON.stringify({ collection, relativePath, content }),
221
+ })
222
+ return jsonOrThrow(res, listUrl)
223
+ }
224
+
225
+ /** DELETE — remove a file from a collection folder. */
226
+ async function remove({ collection, relativePath }) {
227
+ const res = await doFetch(listUrl, {
228
+ method: 'DELETE',
229
+ headers: {
230
+ 'content-type': 'application/json',
231
+ ...defaultHeaders,
232
+ ...bearer(token),
233
+ },
234
+ body: JSON.stringify({ collection, relativePath }),
235
+ })
236
+ return jsonOrThrow(res, listUrl)
237
+ }
238
+
239
+ /**
240
+ * POST /render — render an entity in memory and return the bytes.
241
+ * Decides the return shape from the response's content-type:
242
+ * application/json → parsed JSON
243
+ * text/* → string
244
+ * anything else → ArrayBuffer (PDF, image, etc.)
245
+ */
246
+ async function render(entity, options = {}) {
247
+ const res = await doFetch(renderUrl, {
248
+ method: 'POST',
249
+ headers: {
250
+ 'content-type': 'application/json',
251
+ ...defaultHeaders,
252
+ ...bearer(token),
253
+ },
254
+ body: JSON.stringify({ ...entity, options }),
255
+ })
256
+ if (!res.ok) {
257
+ let body
258
+ try { body = await res.json() } catch {}
259
+ throw new MikserError(res.status, res.statusText, body, renderUrl)
260
+ }
261
+ const ct = res.headers.get('content-type') ?? ''
262
+ if (ct.includes('application/json')) return res.json()
263
+ if (ct.startsWith('text/')) return res.text()
264
+ return res.arrayBuffer()
265
+ }
266
+
267
+ return { list, urlFor, pages, watch, live, update, delete: remove, render }
268
+ }
269
+ }
package/src/error.js ADDED
@@ -0,0 +1,11 @@
1
+ // Error type used across the SDK for non-2xx responses.
2
+ // Callers can `instanceof MikserError` to branch on this specifically.
3
+ export class MikserError extends Error {
4
+ constructor(status, statusText, body, url) {
5
+ const detail = body?.error ? ': ' + body.error : ''
6
+ super(`mikser-io-sdk-api ${status} ${statusText}${detail} (${url})`)
7
+ this.name = 'MikserError'
8
+ this.status = status
9
+ this.body = body
10
+ }
11
+ }
package/src/http.js ADDED
@@ -0,0 +1,17 @@
1
+ // Small HTTP helpers shared across the SDK.
2
+ import { MikserError } from './error.js'
3
+
4
+ export function bearer(token) {
5
+ return token ? { authorization: `Bearer ${token}` } : {}
6
+ }
7
+
8
+ // Throws MikserError on non-2xx so callers can use plain `await`
9
+ // without checking res.ok each time.
10
+ export async function jsonOrThrow(res, url) {
11
+ if (!res.ok) {
12
+ let body
13
+ try { body = await res.json() } catch { /* leave undefined */ }
14
+ throw new MikserError(res.status, res.statusText, body, url)
15
+ }
16
+ return res.json()
17
+ }
package/src/sse.js ADDED
@@ -0,0 +1,20 @@
1
+ // Server-Sent Events parser. Splits the raw body stream into one
2
+ // event block per "\n\n" and decodes each. Used by entities.watch()
3
+ // and entities.live().
4
+
5
+ // Parse one SSE event block ("event: foo\ndata: {...}\n"). Returns
6
+ // { type, ...payload } when data is JSON; { type, data } otherwise.
7
+ // Returns null on completely empty blocks (e.g. comment-only).
8
+ export function parseSseEvent(raw) {
9
+ let type = 'message'
10
+ let data = ''
11
+ let sawAny = false
12
+ for (const line of raw.split('\n')) {
13
+ if (line.startsWith(':')) continue // SSE comment
14
+ if (line.startsWith('event:')) { type = line.slice(6).trim(); sawAny = true; continue }
15
+ if (line.startsWith('data:')) { data += line.slice(5).trim(); sawAny = true; continue }
16
+ }
17
+ if (!sawAny) return null
18
+ try { return { type, ...JSON.parse(data || '{}') } }
19
+ catch { return { type, data } }
20
+ }
package/src/url.js ADDED
@@ -0,0 +1,38 @@
1
+ // URL building helpers. These translate the SDK's filter / sort /
2
+ // projection shapes into the api plugin's GET-form URL params, so a
3
+ // query passed to list() can equivalently land as a URL via urlFor().
4
+
5
+ export function joinUrl(base, path) {
6
+ const normalised = base.endsWith('/') ? base.slice(0, -1) : base
7
+ return normalised + path
8
+ }
9
+
10
+ // { name: 1, date: -1 } → "name,-date"
11
+ export function sortToParam(sort) {
12
+ return Object.entries(sort)
13
+ .map(([k, v]) => (Number(v) < 0 ? `-${k}` : k))
14
+ .join(',')
15
+ }
16
+
17
+ // Walk a filter object and emit URL params using the api plugin's
18
+ // operator-suffix convention:
19
+ //
20
+ // { 'meta.price': { $gt: 20 } } → meta.price.$gt=20
21
+ // { type: 'document' } → type=document
22
+ // { 'meta.tags': { $in: ['a', 'b'] } } → meta.tags.$in=a,b
23
+ //
24
+ // Mutates the URLSearchParams passed in.
25
+ export function filterToParams(filter, params) {
26
+ for (const [key, value] of Object.entries(filter)) {
27
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
28
+ for (const [op, opVal] of Object.entries(value)) {
29
+ const v = Array.isArray(opVal) ? opVal.join(',') : String(opVal)
30
+ params.set(`${key}.${op}`, v)
31
+ }
32
+ } else if (Array.isArray(value)) {
33
+ params.set(key, value.join(','))
34
+ } else if (value != null) {
35
+ params.set(key, String(value))
36
+ }
37
+ }
38
+ }