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 +21 -0
- package/README.md +636 -0
- package/index.d.ts +151 -0
- package/index.js +62 -0
- package/package.json +42 -0
- package/src/entities.js +269 -0
- package/src/error.js +11 -0
- package/src/http.js +17 -0
- package/src/sse.js +20 -0
- package/src/url.js +38 -0
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
|
+
}
|
package/src/entities.js
ADDED
|
@@ -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
|
+
}
|