mikser-io-sdk-api 3.0.0 → 3.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/README.md CHANGED
@@ -227,6 +227,100 @@ Response envelope: `{ items, page, limit, total, totalPages, hasNext, hasPrev }`
227
227
 
228
228
  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.
229
229
 
230
+ ### `expand` — inline-resolve referenced entities in one trip
231
+
232
+ Mikser entities can carry references to other entities via `$`-prefixed front-matter keys ([ADR-0007](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/decisions/0007-references-declaration-and-expansion.md)). On the wire, the SDK projects them back to plain names — so a document with `$author: /authors/dick` in YAML shows up as `meta.author = '/authors/dick'` in the response. **Always**: every response has `$`-keys stripped, regardless of whether you asked for expansion. The convention is engine-side; the wire shape is clean.
233
+
234
+ Pass `expand: [...]` to inline the resolved entity in place of the ref string — multi-hop graph fetches collapse to one round-trip.
235
+
236
+ ```js
237
+ // Source on the server:
238
+ // ---
239
+ // layout: article
240
+ // title: Launch
241
+ // $author: /authors/dick
242
+ // $hero: /images/launch-hero
243
+ // $related: ['/blog/follow-up', '/blog/changelog']
244
+ // ---
245
+
246
+ // Without expand — refs come back as strings (normalized form: no $).
247
+ const { items } = await docs.list({ filter: { id: '/blog/launch.md' } })
248
+ items[0].meta.author // '/authors/dick' — string
249
+ items[0].meta.hero // '/images/launch-hero' — string
250
+ items[0].meta.related // ['/blog/follow-up', '/blog/changelog']
251
+
252
+ // With expand — refs come back as full entity objects.
253
+ const { items: hydrated } = await docs.list({
254
+ filter: { id: '/blog/launch.md' },
255
+ expand: ['author', 'hero'],
256
+ })
257
+ hydrated[0].meta.author.meta.name // 'Dick Marinov'
258
+ hydrated[0].meta.hero.meta.alt // 'Launch screen hero'
259
+ ```
260
+
261
+ **Multi-hop chains** — dot-notation walks through expanded entities:
262
+
263
+ ```js
264
+ const { items } = await docs.list({
265
+ filter: { id: '/blog/launch.md' },
266
+ expand: ['author.organization'],
267
+ })
268
+ items[0].meta.author.meta.organization.meta.name // 'Almero Digital'
269
+ ```
270
+
271
+ Each segment in the path that lands on a `$`-keyed field gets expanded. The path also expands every intermediate hop — `expand: ['author.organization']` expands `$author` AND walks into the resolved author's `$organization`.
272
+
273
+ **Array iteration with `*`** — for sections, related lists, or any `$`-keyed array:
274
+
275
+ ```js
276
+ // $related: ['/blog/follow-up', '/blog/changelog']
277
+ expand: ['related']
278
+ // → meta.related[0] and meta.related[1] are full entity objects
279
+
280
+ // Mixed nesting + iteration — landing page with section blocks:
281
+ // sections:
282
+ // - { type: hero, $image: /images/hero }
283
+ // - { type: features, $image: /images/feat-a }
284
+ expand: ['sections.*.image']
285
+ // → each section's $image becomes the resolved image entity
286
+ ```
287
+
288
+ **Multiple paths in one call**:
289
+
290
+ ```js
291
+ const { items } = await docs.list({
292
+ filter: { id: '/landing.md' },
293
+ expand: [
294
+ 'hero',
295
+ 'sections.*.image',
296
+ 'sections.*.cta.target',
297
+ 'author.organization',
298
+ ],
299
+ })
300
+ ```
301
+
302
+ **Path forms** — the SDK accepts both `'author'` (normalized) and `'$author'` (canonical) and forwards either to the api, which accepts both. Use whichever feels natural at the call site; they're equivalent.
303
+
304
+ **Server-side caps** — exceeding any cap returns a `MikserError` with `status === 422`:
305
+
306
+ | Cap | Default | Configured at | What triggers it |
307
+ |---|---|---|---|
308
+ | `maxDepth` | 5 | `api.expand.maxDepth` | One path is longer than this (`a.b.c.d.e.f` at default) |
309
+ | `maxPaths` | 20 | `api.expand.maxPaths` | The `expand` array has more entries than this |
310
+ | `maxResolved` | 100 | `api.expand.maxResolved` | Total entity lookups for the request (across all paths) exceeded |
311
+
312
+ ```js
313
+ try {
314
+ await docs.list({ filter: {...}, expand: tooManyPaths })
315
+ } catch (err) {
316
+ if (err.status === 422) { /* tighten the expand spec */ }
317
+ }
318
+ ```
319
+
320
+ **Missing targets and cycles are silently left as strings.** If `$author: /authors/missing` doesn't resolve, the response carries `meta.author === '/authors/missing'` — a string at the position you asked to expand. Same shape for cycle breaks. Per ADR-0007 B6 this is by design: the response shape stays consistent, and "string where we asked for an object" is the unambiguous signal that resolution stopped there.
321
+
322
+ **Transport**: same GET-first strategy as the rest of `list()` — `expand` is serialized as a comma-separated URL param when the request fits in the URL, falls back to POST body otherwise. CDN caching works the same way on either form; the `expand` value is part of the cache key.
323
+
230
324
  ### `urlFor(query)` — GET-form URL
231
325
 
232
326
  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.
@@ -235,11 +329,37 @@ Build a URL for the GET form of the same query. Useful when the response should
235
329
  const url = docs.urlFor({
236
330
  filter: { 'meta.published': true, 'meta.price': { $gt: 20 } },
237
331
  sort: { 'meta.date': -1 },
332
+ expand: ['author', 'hero'],
238
333
  limit: 10,
239
334
  })
240
- // http://localhost:3001/api/public/entities?meta.published=true&meta.price.$gt=20&sort=-meta.date&limit=10
335
+ // http://localhost:3001/api/public/entities?
336
+ // meta.published=true&meta.price.$gt=20&sort=-meta.date&expand=author,hero&limit=10
337
+ ```
338
+
339
+ `expand` is serialized as a comma-separated value so the URL is a stable cache key — same response for the same URL across requests and CDN nodes.
340
+
341
+ ### `cacheKeyFor(query)` — the nginx fast-path hint
342
+
343
+ The api plugin caches GET responses to disk under a filename derived from the query. Both server and SDK hash the same query the same way, so `list()` appends `&cache=<hash>` to its GET URL automatically — nginx can then serve cached files via `try_files` without computing the hash itself (no Lua, no rewrite module). See the [api plugin's caching docs](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/caching.md) for the full nginx config.
344
+
345
+ ```js
346
+ const query = {
347
+ filter: { 'meta.published': true },
348
+ expand: ['author'],
349
+ limit: 10,
350
+ }
351
+ const key = await docs.cacheKeyFor(query)
352
+ // '4f3a2c1d8e9b6f7a' (16 hex chars; same value the server uses on disk)
353
+
354
+ // Compose a cacheable URL by hand (rare — list() does this automatically).
355
+ // urlFor() guarantees a `?` because the query has params; for the empty-
356
+ // query case `cacheKeyFor()` returns `'index'` and you'd skip appending
357
+ // `cache` (the server stores empty-query responses as `index.json`).
358
+ const url = docs.urlFor(query) + '&cache=' + key
241
359
  ```
242
360
 
361
+ Returns `'index'` for empty queries — the server stores that case under `index.json`, the conventional default-snapshot name a `try_files` directive falls through to. The server strips the `cache` param before computing its own hash, so a wrong/stale client value just causes a cache miss in nginx (graceful fallback to mikser); no poisoning is possible because the server is always the source of truth for filename choice.
362
+
243
363
  ### `pages(query)` — async iterator
244
364
 
245
365
  ```js
@@ -250,6 +370,53 @@ for await (const env of docs.pages({ filter: { type: 'document' }, limit: 50 }))
250
370
  }
251
371
  ```
252
372
 
373
+ `pages()` and `listAll()` both accept `expand` in the query — the parameter applies to every page in the iteration. For sitemap-style enumeration with one-hop hydration (`expand: ['author']`), this keeps the build to one round-trip per page rather than N per entity.
374
+
375
+ ### `paginator(options)` — stateful client-side paginator
376
+
377
+ Wraps `list()` with page-at-a-time state. Each navigation call (`goTo` / `next` / `prev`) fetches exactly **one page** from the server — no upfront load of the full collection. Right for UI navigation; use `pages()` or `listAll()` for SSG sitemap enumeration that needs every page.
378
+
379
+ ```js
380
+ const docs = client.entities('public')
381
+
382
+ const paginator = docs.paginator({
383
+ filter: { 'meta.layout': 'post' },
384
+ sort: { 'meta.date': -1 },
385
+ pageSize: 10,
386
+ })
387
+
388
+ await paginator.goTo(1) // one HTTP request → first 10 items
389
+ paginator.items // those 10 items
390
+ paginator.page // 1
391
+ paginator.pages // total page count (server-computed)
392
+ paginator.totalItems // total item count
393
+ paginator.hasNext, hasPrev
394
+ paginator.pageNumbers // [{ num, url, isCurrent }, ...]
395
+
396
+ await paginator.next() // one HTTP request → items 11..20
397
+ await paginator.goTo(5) // one HTTP request → items 41..50
398
+ ```
399
+
400
+ State accessors are getters — the value you read is always the result of the last completed fetch. Wrap in your framework's reactive primitive (React's `useState`, Vue's `ref`, Svelte stores) to re-render when navigation completes.
401
+
402
+ Options:
403
+ - `filter`, `sort`, `fields`, `expand` — same shape as `list()`. Applied on every page fetch.
404
+ - `pageSize` (default `10`) — items per page. Positive integer.
405
+ - `urlFor` (default `(p) => p === 1 ? '/' : '/<p>/'`) — build per-page hrefs for the `pageNumbers` array. Override for SPA hash routing (`(p) => '#/page/' + p`) or query-param style (`(p) => '?page=' + p`).
406
+
407
+ Errors thrown by `next()` at the last page, `prev()` at the first page, and `goTo()` on an invalid page number — so a UI layer can disable nav controls based on `hasNext` / `hasPrev` rather than catch.
408
+
409
+ #### vs. `list()` directly
410
+
411
+ | Use | Pattern |
412
+ |---|---|
413
+ | One-shot fetch of a specific page | `await docs.list({ filter, page: 3, limit: 10 })` |
414
+ | Stateful UI navigation | `const p = docs.paginator({ ... }); await p.next()` |
415
+ | Server enumeration (SSG, indexing) | `for await (const env of docs.pages({ filter }))` |
416
+ | Live-updating feed | `docs.live(filter, onChange)` |
417
+
418
+ `paginator` is sugar over `list` for the most common UI pattern — keeps the "current page" out of your component state.
419
+
253
420
  ### `watch(query, { signal })` — live subscription via SSE
254
421
 
255
422
  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.
@@ -293,6 +460,7 @@ const dispose = docs.live(
293
460
  {
294
461
  sort: { 'meta.date': -1 },
295
462
  fields: ['id', 'meta.title', 'meta.date', 'meta.summary'],
463
+ expand: ['author', 'hero'], // hydrate refs on the initial snapshot
296
464
  limit: 20,
297
465
  signal: abortController?.signal, // optional external abort
298
466
  onError: err => console.error(err), // optional error sink
@@ -303,6 +471,8 @@ const dispose = docs.live(
303
471
  dispose()
304
472
  ```
305
473
 
474
+ **`expand` and SSE deltas.** When passed in `options`, `expand` is applied to the *initial* snapshot. The SSE delta stream that follows emits raw entities — a `create` or `update` event replaces the previously-expanded item with the unexpanded shape until the next refetch. If you need always-expanded items through live updates, call `list({ ..., expand })` on the cadence you care about instead, or wait for server-side expand-on-subscribe (not shipped yet — track ADR-0007 follow-ups).
475
+
306
476
  Equivalent to:
307
477
 
308
478
  ```js
package/index.d.ts CHANGED
@@ -139,6 +139,60 @@ export interface LiveOptions {
139
139
  onError?: (err: unknown) => void
140
140
  }
141
141
 
142
+ /**
143
+ * Options for {@link EntitiesClient.paginator}.
144
+ */
145
+ export interface PaginatorOptions extends Omit<ListQuery, 'page' | 'limit' | 'skip'> {
146
+ /** Items per page. Default: 10. Must be a positive integer. */
147
+ pageSize?: number
148
+ /**
149
+ * Builds the href for each pageNumbers entry. Defaults to mikser's
150
+ * SSG URL convention — page 1 at `/`, page N at `/<N>/`.
151
+ */
152
+ urlFor?: (page: number) => string
153
+ }
154
+
155
+ /**
156
+ * One entry in the paginator's pageNumbers array — usable directly
157
+ * by pagination nav components.
158
+ */
159
+ export interface PageNumber {
160
+ num: number
161
+ url: string
162
+ isCurrent: boolean
163
+ }
164
+
165
+ /**
166
+ * Stateful paginator returned from {@link EntitiesClient.paginator}.
167
+ * Each navigation method (goTo / next / prev) fetches ONE page from
168
+ * the server. State accessors are getters — the value you read is
169
+ * always the result of the last completed fetch.
170
+ */
171
+ export interface Paginator<T = unknown> {
172
+ /** Items in the current page. Empty until goTo() / next() has resolved. */
173
+ readonly items: T[]
174
+ /** Current page number (1-indexed). 1 before the first fetch. */
175
+ readonly page: number
176
+ /** Total page count (server-computed). 1 before the first fetch. */
177
+ readonly pages: number
178
+ /** Total item count across all pages (server-computed). */
179
+ readonly totalItems: number
180
+ /** Page size used for all fetches. */
181
+ readonly pageSize: number
182
+ readonly hasNext: boolean
183
+ readonly hasPrev: boolean
184
+ /** True after the first successful fetch. */
185
+ readonly loaded: boolean
186
+ /** Per-page nav entries — `[{ num, url, isCurrent }, ...]`. */
187
+ readonly pageNumbers: PageNumber[]
188
+ /** Fetch a specific page. Throws on invalid page number. */
189
+ goTo(page: number): Promise<Paginator<T>>
190
+ /** Fetch the next page. Throws if already at the last page. */
191
+ next(): Promise<Paginator<T>>
192
+ /** Fetch the previous page. Throws if already at the first page. */
193
+ prev(): Promise<Paginator<T>>
194
+ }
195
+
142
196
  export interface EntitiesClient {
143
197
  /** POST /entities/query — body-based, supports any sift filter. */
144
198
  list<T = unknown>(query?: ListQuery): Promise<ListEnvelope<T>>
@@ -153,6 +207,13 @@ export interface EntitiesClient {
153
207
  * for catalogs too large to hold in memory.
154
208
  */
155
209
  listAll<T = unknown>(query?: ListQuery): Promise<T[]>
210
+ /**
211
+ * Stateful paginator over list(). Each goTo / next / prev fetches
212
+ * ONE page from the server — no upfront load of the full
213
+ * collection. Right for UI navigation; wrong for SSG sitemap
214
+ * enumeration (use pages() or listAll() for that).
215
+ */
216
+ paginator<T = unknown>(options?: PaginatorOptions): Paginator<T>
156
217
  /**
157
218
  * Open an SSE stream and yield events as matching entities change.
158
219
  * Compose with list() for initial state, then watch() for updates.
@@ -187,3 +248,87 @@ export declare class MikserError extends Error {
187
248
  status: number
188
249
  body: { error?: string } | undefined
189
250
  }
251
+
252
+ // ────────────────────────────────────────────────────────────────────
253
+ // Pure utilities — framework SDKs wrap these in their own reactivity.
254
+ // ────────────────────────────────────────────────────────────────────
255
+
256
+ export interface GenerateRoutesOptions<TRoute = unknown> {
257
+ /** A mikser entities client (the result of createClient(...).entities(name)). */
258
+ client: { listAll(query: ListQuery): Promise<Array<{ id: string; meta?: Record<string, unknown> }>> }
259
+ /** Sift filter — defaults to "published documents that declare meta.route". */
260
+ filter?: Filter
261
+ /** Maps a catalog document to a route descriptor (or null to skip). */
262
+ mapRoute: (document: { id: string; meta?: Record<string, unknown> }) => TRoute | null
263
+ }
264
+
265
+ /**
266
+ * Build-time route enumeration. Auto-paginates via listAll() and
267
+ * applies `mapRoute` to every catalog entity that matches the filter.
268
+ * `null` returns from `mapRoute` are dropped.
269
+ */
270
+ export function generateMikserRoutes<TRoute = unknown>(
271
+ options: GenerateRoutesOptions<TRoute>,
272
+ ): Promise<TRoute[]>
273
+
274
+ export interface HrefIndexOptions {
275
+ /** Fallback language tag for documents that don't declare meta.lang. Default 'default'. */
276
+ defaultLang?: string
277
+ }
278
+
279
+ export interface HrefIndex {
280
+ /** Resolve a logical reference (`/about`) to a deployed URL for the given language. */
281
+ href(ref: string, lang?: string): string
282
+ /** Reverse — given a deployed URL, return the logical reference it belongs to. */
283
+ refFor(url: string | null): string | null
284
+ /** Alternates for a deployed URL — `current` plus the alternate-language URLs. */
285
+ alternates(options: { route: string | null; languages?: string[] }): {
286
+ current: { lang: string | null; url: string; ref: string } | null
287
+ alternates: Array<{ lang: string; url: string }>
288
+ }
289
+ /** Raw `ref → { lang → url }` map, for inspection / debugging. */
290
+ map: Record<string, Record<string, string>>
291
+ }
292
+
293
+ /**
294
+ * Build a multilingual href lookup from a snapshot of catalog documents.
295
+ * Pure data transformation — wrap in a framework-specific reactive
296
+ * shell to drive `useHref` / `useAlternates` composables.
297
+ */
298
+ export function createHrefIndex(
299
+ documents: Array<{ meta?: Record<string, unknown> }>,
300
+ options?: HrefIndexOptions,
301
+ ): HrefIndex
302
+
303
+ export interface AssetRecord {
304
+ url: string
305
+ width?: number
306
+ height?: number
307
+ srcset?: string
308
+ alt?: string
309
+ meta?: Record<string, unknown>
310
+ }
311
+
312
+ export interface ImageProps {
313
+ src: string
314
+ width?: number
315
+ height?: number
316
+ srcset?: string
317
+ alt?: string
318
+ }
319
+
320
+ export interface AssetIndex {
321
+ asset(ref: string): AssetRecord | null
322
+ image(ref: string): ImageProps | null
323
+ map: Record<string, AssetRecord>
324
+ }
325
+
326
+ /**
327
+ * Build an asset metadata lookup from a snapshot of asset entities.
328
+ * Pure data transformation — wrap in a framework-specific reactive
329
+ * shell to drive `useAsset` composables.
330
+ */
331
+ export function createAssetIndex(
332
+ assets: Array<{ id: string; meta?: Record<string, unknown> }>,
333
+ ): AssetIndex
334
+
package/index.js CHANGED
@@ -27,6 +27,9 @@
27
27
  // src/url.js — URL building (joinUrl, sortToParam, filterToParams)
28
28
  // src/sse.js — SSE event parser
29
29
  // src/entities.js — per-endpoint entities client (list / watch / live / ...)
30
+ // src/routes.js — generateMikserRoutes (build-time route enumeration)
31
+ // src/href.js — createHrefIndex (multilingual reference → URL lookup)
32
+ // src/asset.js — createAssetIndex (asset metadata lookup)
30
33
  import { MikserError } from './src/error.js'
31
34
  import { createEntitiesClient } from './src/entities.js'
32
35
 
@@ -60,3 +63,6 @@ export function createClient({
60
63
  }
61
64
 
62
65
  export { MikserError }
66
+ export { generateMikserRoutes } from './src/routes.js'
67
+ export { createHrefIndex } from './src/href.js'
68
+ export { createAssetIndex } from './src/asset.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-sdk-api",
3
- "version": "3.0.0",
3
+ "version": "3.3.0",
4
4
  "description": "Client SDK for mikser-io's api plugin — query the document catalog from the browser or Node",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -23,7 +23,7 @@
23
23
  "test:run": "vitest run"
24
24
  },
25
25
  "devDependencies": {
26
- "vitest": "^2.0.0"
26
+ "vitest": "^4.1.8"
27
27
  },
28
28
  "repository": {
29
29
  "type": "git",
package/src/asset.js ADDED
@@ -0,0 +1,65 @@
1
+ // Asset metadata index — pure data version. Framework SDKs wrap this
2
+ // in their own reactivity primitives and expose useAsset on top.
3
+ //
4
+ // When assets carry metadata the template needs (dimensions, srcset,
5
+ // alt text), looking them up by reference is cleaner than re-fetching
6
+ // per render. The convention is that asset entities have an `id`
7
+ // (used as the reference key) plus a `meta` block with the metadata.
8
+
9
+ /**
10
+ * @param {Array<{id: string, meta?: object}>} assets Asset entities.
11
+ * @returns {{
12
+ * asset: (ref: string) => AssetRecord|null,
13
+ * image: (ref: string) => ImageProps|null,
14
+ * map: Record<string, AssetRecord>,
15
+ * }}
16
+ *
17
+ * @typedef {Object} AssetRecord
18
+ * @property {string} url
19
+ * @property {number|undefined} width
20
+ * @property {number|undefined} height
21
+ * @property {string|undefined} srcset
22
+ * @property {string|undefined} alt
23
+ * @property {object|undefined} meta The raw meta block, for downstream use.
24
+ *
25
+ * @typedef {Object} ImageProps
26
+ * @property {string} src
27
+ * @property {number|undefined} width
28
+ * @property {number|undefined} height
29
+ * @property {string|undefined} srcset
30
+ * @property {string|undefined} alt
31
+ */
32
+ export function createAssetIndex(assets) {
33
+ const map = {}
34
+ if (Array.isArray(assets)) {
35
+ for (const a of assets) {
36
+ if (!a?.id) continue
37
+ map[a.id] = {
38
+ url: a.meta?.destination ?? a.meta?.url ?? a.id,
39
+ width: a.meta?.width,
40
+ height: a.meta?.height,
41
+ srcset: a.meta?.srcset,
42
+ alt: a.meta?.alt,
43
+ meta: a.meta,
44
+ }
45
+ }
46
+ }
47
+
48
+ function asset(ref) {
49
+ return map[ref] ?? null
50
+ }
51
+
52
+ function image(ref) {
53
+ const a = map[ref]
54
+ if (!a) return null
55
+ return {
56
+ src: a.url,
57
+ width: a.width,
58
+ height: a.height,
59
+ srcset: a.srcset,
60
+ alt: a.alt,
61
+ }
62
+ }
63
+
64
+ return { asset, image, map }
65
+ }
package/src/entities.js CHANGED
@@ -7,6 +7,36 @@ import { bearer, jsonOrThrow } from './http.js'
7
7
  import { joinUrl, sortToParam, filterToParams } from './url.js'
8
8
  import { parseSseEvent } from './sse.js'
9
9
 
10
+ // Build the URLSearchParams body that both `urlFor()` and `cacheKeyFor()`
11
+ // serialize. Extracted as a helper so the two paths can't drift — the
12
+ // cache-key hash MUST be computed against the same byte sequence the
13
+ // GET URL carries, otherwise client and server will land on different
14
+ // filenames.
15
+ function buildQueryParams(query, params) {
16
+ const { filter, sort, fields, page, limit, skip, expand } = query
17
+ if (page != null) params.set('page', String(page))
18
+ if (limit != null) params.set('limit', String(limit))
19
+ if (skip != null) params.set('skip', String(skip))
20
+ if (sort) params.set('sort', sortToParam(sort))
21
+ if (fields) params.set('fields', fields.join(','))
22
+ if (expand && expand.length) params.set('expand', expand.join(','))
23
+ if (filter) filterToParams(filter, params)
24
+ }
25
+
26
+ // First 16 hex chars of sha256(str) — matches the server's algorithm in
27
+ // `cacheNameForQueryString`. Uses the standard Web Crypto API which is
28
+ // available in browsers and Node 18+ as `globalThis.crypto.subtle`.
29
+ async function sha256HexPrefix16(str) {
30
+ const bytes = new TextEncoder().encode(str)
31
+ const buf = await globalThis.crypto.subtle.digest('SHA-256', bytes)
32
+ const arr = new Uint8Array(buf)
33
+ let hex = ''
34
+ for (let i = 0; i < 8; i++) {
35
+ hex += arr[i].toString(16).padStart(2, '0')
36
+ }
37
+ return hex
38
+ }
39
+
10
40
  // Conservative URL-length ceiling for the GET form of list(). Real
11
41
  // browsers and proxies vary (Chrome ~32k, IIS ~16k, nginx default
12
42
  // 8k, some CDNs 4k), but the safest interop floor for list-with-
@@ -218,8 +248,25 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
218
248
  const forcePost = opts.method === 'POST'
219
249
 
220
250
  if (!forcePost) {
221
- const url = urlFor(query)
251
+ let url = urlFor(query)
222
252
  if (url.length <= GET_MAX_URL) {
253
+ // Append the cache-key routing hint per the nginx
254
+ // try_files contract. Server strips this param
255
+ // before hashing the query, so client and server
256
+ // agree on the cache filename. Without this hint
257
+ // nginx can't serve cache files directly (it would
258
+ // need Lua to compute the hash from $args). See
259
+ // ADR-0007 §B9 and the api plugin's
260
+ // cacheNameForQueryString comment block.
261
+ //
262
+ // Skipped when there's nothing to hash (empty
263
+ // query → 'index.json' on the server).
264
+ const cacheKey = await cacheKeyFor(query)
265
+ if (cacheKey !== 'index') {
266
+ url = url.includes('?')
267
+ ? `${url}&cache=${cacheKey}`
268
+ : `${url}?cache=${cacheKey}`
269
+ }
223
270
  const res = await doFetch(url, {
224
271
  method: 'GET',
225
272
  headers: {
@@ -253,19 +300,47 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
253
300
  * Build a URL for the GET form of the same query — useful when
254
301
  * the response should be CDN-cacheable, or when the caller wants
255
302
  * a sharable link. Operators map to `.$op` URL-param suffixes.
303
+ *
304
+ * `expand` is serialized as a comma-separated list per ADR-0007
305
+ * B7 (api keeps the GET form cache-stable). Paths can use either
306
+ * canonical (`$author`) or normalized (`author`) form — the api
307
+ * accepts both. Each entry walks $-keyed reference fields and
308
+ * inlines the resolved entity in the response. Use `*` for array
309
+ * iteration: `['sections.*.image']`.
256
310
  */
257
311
  function urlFor(query = {}) {
258
312
  const url = new URL(listUrl)
259
- const { filter, sort, fields, page, limit, skip } = query
260
- if (page != null) url.searchParams.set('page', String(page))
261
- if (limit != null) url.searchParams.set('limit', String(limit))
262
- if (skip != null) url.searchParams.set('skip', String(skip))
263
- if (sort) url.searchParams.set('sort', sortToParam(sort))
264
- if (fields) url.searchParams.set('fields', fields.join(','))
265
- if (filter) filterToParams(filter, url.searchParams)
313
+ buildQueryParams(query, url.searchParams)
266
314
  return url.toString()
267
315
  }
268
316
 
317
+ /**
318
+ * Compute the cache-routing-hint key for a query — the same hash
319
+ * the server would store the response under. Use it to build a
320
+ * URL that nginx can serve from disk via:
321
+ *
322
+ * try_files /api/<endpoint>/entities/$arg_cache.json @proxy
323
+ *
324
+ * The default `list()` already appends this automatically. Call
325
+ * `cacheKeyFor()` directly when you need the bare hash — e.g.
326
+ * to build a cacheable URL for a sharable link or for a custom
327
+ * fetch path. Returns `'index'` when the query has no params
328
+ * (the server caches that case under `index.json`).
329
+ *
330
+ * Algorithm: build the same URLSearchParams `list()` would
331
+ * build, take its `.toString()`, sha256 the bytes, take the
332
+ * first 16 hex chars (64 bits). Server uses the same algorithm
333
+ * — collisions are vanishingly unlikely across realistic cache
334
+ * populations.
335
+ */
336
+ async function cacheKeyFor(query = {}) {
337
+ const params = new URLSearchParams()
338
+ buildQueryParams(query, params)
339
+ const search = params.toString()
340
+ if (!search) return 'index'
341
+ return await sha256HexPrefix16(search)
342
+ }
343
+
269
344
  /**
270
345
  * Iterate result pages without manual page bookkeeping. Yields
271
346
  * each response envelope until `hasNext` is false.
@@ -340,6 +415,11 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
340
415
  async function* watch(query = {}, { signal } = {}) {
341
416
  const url = new URL(subscribeUrl)
342
417
  if (query.filter) filterToParams(query.filter, url.searchParams)
418
+ // Forward `expand` to the server so the graph-subscription
419
+ // path is engaged (the api delegates to runtime.refs and
420
+ // emits already-expanded entities). Without expand the SSE
421
+ // stream emits bare entities — existing behavior preserved.
422
+ if (query.expand?.length) url.searchParams.set('expand', query.expand.join(','))
343
423
 
344
424
  const res = await doFetch(url.toString(), {
345
425
  method: 'GET',
@@ -407,6 +487,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
407
487
  function live(filter, onChange, options = {}) {
408
488
  const {
409
489
  sort, fields, limit, skip,
490
+ expand,
410
491
  quiet,
411
492
  signal: externalSignal,
412
493
  onError = (err) => console.error('mikser-io-sdk-api live error:', err),
@@ -477,13 +558,21 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
477
558
  if (!usedFastPath) {
478
559
  // Pass { quiet } so list()'s wide-warning honors
479
560
  // the live() caller's quiet opt.
480
- const env = await list({ filter, sort, fields, limit, skip }, { quiet })
561
+ //
562
+ // `expand` flows through to the initial snapshot
563
+ // call AND to watch() below, so the server's
564
+ // graph-subscription path (runtime.refs) emits
565
+ // already-expanded entities on every mutation
566
+ // within the expansion graph. Update events
567
+ // replace items in place with the new expanded
568
+ // shape, keeping the consumer's view consistent.
569
+ const env = await list({ filter, sort, fields, limit, skip, expand }, { quiet })
481
570
  if (disposed || ac.signal.aborted) return
482
571
  items = env.items
483
572
  onChange(items)
484
573
  }
485
574
 
486
- for await (const event of watch({ filter }, { signal: ac.signal })) {
575
+ for await (const event of watch({ filter, expand }, { signal: ac.signal })) {
487
576
  if (disposed) return
488
577
  switch (event.type) {
489
578
  case 'create':
@@ -578,7 +667,116 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
578
667
  return res.arrayBuffer()
579
668
  }
580
669
 
581
- return { list, listAll, urlFor, pages, watch, live, update, delete: remove, render }
670
+ /**
671
+ * Stateful paginator over list(). Each call to goTo / next /
672
+ * prev fetches ONE page from the server — the items the
673
+ * server returns are exactly the slice the user asked for.
674
+ * No upfront load of the full collection.
675
+ *
676
+ * const paginator = docs.paginator({
677
+ * filter: { 'meta.layout': 'post' },
678
+ * sort: { 'meta.date': -1 },
679
+ * pageSize: 10,
680
+ * })
681
+ * await paginator.goTo(1)
682
+ * paginator.items, paginator.page, paginator.pages, paginator.hasNext
683
+ * await paginator.next()
684
+ *
685
+ * State accessors are getters — the value you read is always
686
+ * the result of the last completed fetch. Wrap in your
687
+ * framework's reactive primitive (useState / ref / store) to
688
+ * re-render when navigation completes.
689
+ *
690
+ * @param {Object} [opts]
691
+ * @param {Object} [opts.filter] Same as list() filter
692
+ * @param {Object} [opts.sort] Same as list() sort
693
+ * @param {string[]} [opts.fields] Same as list() fields
694
+ * @param {string[]} [opts.expand] Same as list() expand
695
+ * @param {number} [opts.pageSize=10] Items per page
696
+ * @param {(page:number)=>string} [opts.urlFor] Builds the
697
+ * href for each pageNumbers entry. Default matches mikser's
698
+ * SSG convention (`/` for page 1, `/<N>/` for the rest).
699
+ */
700
+ function paginator({
701
+ pageSize = 10,
702
+ urlFor: urlForFn,
703
+ ...query
704
+ } = {}) {
705
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
706
+ throw new TypeError(`paginator: pageSize must be a positive integer (got ${pageSize})`)
707
+ }
708
+
709
+ const buildUrl = urlForFn || ((p) => p === 1 ? '/' : `/${p}/`)
710
+
711
+ let state = {
712
+ page: 1,
713
+ items: [],
714
+ pages: 1,
715
+ totalItems: 0,
716
+ loaded: false,
717
+ }
718
+
719
+ function pageNumbers() {
720
+ const out = []
721
+ for (let p = 1; p <= state.pages; p++) {
722
+ out.push({ num: p, url: buildUrl(p), isCurrent: p === state.page })
723
+ }
724
+ return out
725
+ }
726
+
727
+ async function goTo(page) {
728
+ if (!Number.isInteger(page) || page < 1) {
729
+ throw new RangeError(`paginator.goTo: page must be a positive integer (got ${page})`)
730
+ }
731
+ const result = await list({
732
+ ...query,
733
+ page,
734
+ limit: pageSize,
735
+ })
736
+ state = {
737
+ page: result.page ?? page,
738
+ items: result.items ?? [],
739
+ pages: result.totalPages ?? 1,
740
+ totalItems: result.total ?? 0,
741
+ loaded: true,
742
+ }
743
+ return api
744
+ }
745
+
746
+ async function next() {
747
+ if (!state.loaded) return goTo(1)
748
+ if (state.page >= state.pages) {
749
+ throw new RangeError('paginator.next: already at the last page')
750
+ }
751
+ return goTo(state.page + 1)
752
+ }
753
+
754
+ async function prev() {
755
+ if (!state.loaded) return goTo(1)
756
+ if (state.page <= 1) {
757
+ throw new RangeError('paginator.prev: already at the first page')
758
+ }
759
+ return goTo(state.page - 1)
760
+ }
761
+
762
+ const api = {
763
+ get items() { return state.items },
764
+ get page() { return state.page },
765
+ get pages() { return state.pages },
766
+ get totalItems() { return state.totalItems },
767
+ get pageSize() { return pageSize },
768
+ get hasNext() { return state.page < state.pages },
769
+ get hasPrev() { return state.page > 1 },
770
+ get loaded() { return state.loaded },
771
+ get pageNumbers() { return pageNumbers() },
772
+ goTo,
773
+ next,
774
+ prev,
775
+ }
776
+ return api
777
+ }
778
+
779
+ return { list, listAll, urlFor, cacheKeyFor, pages, paginator, watch, live, update, delete: remove, render }
582
780
  }
583
781
  }
584
782
 
package/src/href.js ADDED
@@ -0,0 +1,110 @@
1
+ // Multilingual href resolution — pure data version. Framework SDKs
2
+ // (Vue/React/Svelte) wrap this in their own reactivity primitives and
3
+ // expose useHref / useAlternates composables on top.
4
+ //
5
+ // The "logical reference → deployed URL" mapping lets the consumer
6
+ // link to a content-shaped identifier (`/about`) and have it resolve
7
+ // to whatever URL the current locale serves it at (`/en/about`,
8
+ // `/fr/a-propos`, etc.). Convention is three meta fields:
9
+ //
10
+ // meta.href: '/about' (logical reference)
11
+ // meta.lang: 'en' (language this doc represents)
12
+ // meta.route: '/en/about' (deployed URL)
13
+ //
14
+ // Anything missing `meta.href` is excluded from the index.
15
+
16
+ /**
17
+ * @param {Array<{meta?: object}>} documents Catalog documents with meta.
18
+ * @param {Object} [options]
19
+ * @param {string} [options.defaultLang='default'] Fallback language tag
20
+ * for documents that don't declare meta.lang. The literal string
21
+ * `'default'` is treated as a fallback bucket in lookups.
22
+ * @returns {{
23
+ * href: (ref: string, lang?: string) => string,
24
+ * refFor: (url: string|null) => string|null,
25
+ * alternates: (opts: { route: string|null, languages?: string[] }) => { current: {lang, url, ref}|null, alternates: Array<{lang, url}> },
26
+ * map: Record<string, Record<string, string>>,
27
+ * }}
28
+ */
29
+ export function createHrefIndex(documents, { defaultLang = 'default' } = {}) {
30
+ const map = {}
31
+ if (Array.isArray(documents)) {
32
+ for (const document of documents) {
33
+ const ref = document?.meta?.href
34
+ if (!ref) continue
35
+ const lang = document.meta?.lang ?? defaultLang
36
+ const url = document.meta?.route ?? document.meta?.destination ?? ref
37
+ if (!map[ref]) map[ref] = {}
38
+ map[ref][lang] = url
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve a logical reference to a deployed URL.
44
+ *
45
+ * Fallback chain: requested lang → `'default'` bucket → any
46
+ * available language → the input reference unchanged (so broken
47
+ * references stay visible rather than silently becoming undefined).
48
+ */
49
+ function href(ref, lang) {
50
+ const target = lang ?? defaultLang
51
+ const entry = map[ref]
52
+ if (!entry) return ref
53
+ return entry[target]
54
+ ?? entry['default']
55
+ ?? Object.values(entry)[0]
56
+ ?? ref
57
+ }
58
+
59
+ /**
60
+ * Reverse lookup — given a deployed URL, return the logical
61
+ * reference it belongs to (or null if it's not in the index).
62
+ */
63
+ function refFor(url) {
64
+ if (url == null) return null
65
+ for (const [ref, byLang] of Object.entries(map)) {
66
+ if (Object.values(byLang).includes(url)) return ref
67
+ }
68
+ return null
69
+ }
70
+
71
+ /**
72
+ * Alternates for a deployed URL — useful for hreflang tags and
73
+ * language switchers.
74
+ *
75
+ * `languages` controls the alternate set:
76
+ * - omitted: only return languages that actually exist in the
77
+ * catalog for this ref. Right shape for hreflang (don't
78
+ * advertise translations that don't exist).
79
+ * - provided as an array: return one entry per requested
80
+ * language, using href()'s fallback chain when a translation
81
+ * doesn't exist. Right shape for language switchers (show
82
+ * every locale the app supports).
83
+ *
84
+ * The current page's own language is excluded from `alternates` —
85
+ * it's what `current` is for. Callers that want it included can
86
+ * prepend `current` themselves.
87
+ */
88
+ function alternates({ route, languages } = {}) {
89
+ if (route == null) return { current: null, alternates: [] }
90
+ const ref = refFor(route)
91
+ if (ref == null) return { current: null, alternates: [] }
92
+ const entry = map[ref] ?? {}
93
+ const currentLang = Object.entries(entry).find(([, url]) => url === route)?.[0] ?? null
94
+ const current = { lang: currentLang, url: route, ref }
95
+
96
+ let list
97
+ if (Array.isArray(languages)) {
98
+ list = languages
99
+ .filter(lang => lang !== currentLang)
100
+ .map(lang => ({ lang, url: href(ref, lang) }))
101
+ } else {
102
+ list = Object.entries(entry)
103
+ .filter(([lang]) => lang !== currentLang && lang !== 'default')
104
+ .map(([lang, url]) => ({ lang, url }))
105
+ }
106
+ return { current, alternates: list }
107
+ }
108
+
109
+ return { href, refFor, alternates, map }
110
+ }
package/src/routes.js ADDED
@@ -0,0 +1,34 @@
1
+ // Build-time route enumeration. Given a mikser entities client and a
2
+ // mapRoute function, return the array of route descriptors produced by
3
+ // applying `mapRoute` to every catalog entity that matches the filter.
4
+ //
5
+ // Auto-paginates via the client's listAll() under the hood — no manual
6
+ // limit, no silent truncation on large catalogs.
7
+ //
8
+ // Framework-agnostic: the `mapRoute` return shape is whatever your build
9
+ // pipeline expects (vite-ssg routes, Next pages, SvelteKit entries, etc.).
10
+ // Framework SDKs re-export this with their own typed mapRoute signatures.
11
+
12
+ const DEFAULT_FILTER = { 'meta.published': true, 'meta.route': { $exists: true } }
13
+
14
+ /**
15
+ * @param {Object} options
16
+ * @param {Object} options.client A mikser entities client (the result of
17
+ * createClient(...).entities(name)).
18
+ * @param {Object} [options.filter] Sift filter — defaults to "published
19
+ * documents that declare meta.route".
20
+ * @param {Function} options.mapRoute (document) => routeDescriptor | null.
21
+ * Null returns are dropped.
22
+ * @returns {Promise<Array>} The mapped route descriptors.
23
+ */
24
+ export async function generateMikserRoutes({
25
+ client,
26
+ filter = DEFAULT_FILTER,
27
+ mapRoute,
28
+ } = {}) {
29
+ if (!client) throw new Error('generateMikserRoutes: { client } is required')
30
+ if (!mapRoute) throw new Error('generateMikserRoutes: { mapRoute } is required')
31
+
32
+ const items = await client.listAll({ filter, fields: ['id', 'meta'] })
33
+ return items.map(mapRoute).filter(r => r != null)
34
+ }