mikser-io 6.23.1 → 6.27.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
@@ -40,6 +40,8 @@ Build mikser into the parts of your application that are content-shaped. Keep th
40
40
 
41
41
  **Run anywhere.** The same CLI handles one-shot builds, watch-mode dev loops, and a long-running HTTP server with a shared Express app. `npx mikser` ships a static site; `mikser --watch` is the dev loop; `mikser --server` exposes a live admin/API.
42
42
 
43
+ **Survives backend outages.** The `api` plugin's [per-query disk cache](./documentation/caching.md) writes every cacheable list response to `out/` keyed by the request URL. A stock-nginx reverse proxy can fail over to the cached file when mikser is unreachable — same URL, transparent to the client, no Lua or extra modules required. Production frontends keep rendering routes during deploys, brief outages, and the upstream-blip-of-the-week. Opt in per endpoint with `cache: true`; see [Caching and reverse-proxy failover](./documentation/caching.md) for the working config.
44
+
43
45
  **Library mode.** Mikser is also a library. `useRenderer`, `useCollection`, `findSimilar`, and direct lifecycle hooks let you embed the engine inside an existing Node app instead of running it as a CLI.
44
46
 
45
47
  **Open source.** MIT-licensed, on GitHub, no telemetry, no auth wall, no SaaS dependency. What you see is what runs.
@@ -58,6 +60,8 @@ Files-as-source isn't just a portability story — it makes the project unusuall
58
60
 
59
61
  **Plugin-by-example.** Authoring a new plugin? There are 15+ existing ones in the same shape to pattern-match against. Convention is dense enough that new plugins look like the old ones without coaching.
60
62
 
63
+ **One-shot bootstrap via Claude Code.** The [`mikser-io-claude-plugin`](https://github.com/almero-digital-marketing/mikser-io-claude-plugin) wraps the setup story above into a single skill. Register the repo as a Claude Code marketplace and install the plugin (`/plugin marketplace add almero-digital-marketing/mikser-io-claude-plugin` then `/plugin install mikser-io-claude-plugin@mikser-io`), then in any Vue 3, React, or SvelteKit project — or in a blank directory — say "add mikser to this app." It detects the framework (or scaffolds a fresh starter via `create-vite` / `sv create`), wires the matching framework SDK, composes with your existing router rather than replacing it, and optionally lays down a `mikser-content/` sibling folder with Zod schemas and starter documents so the backend works on first run.
64
+
61
65
  The honest caveat: this advantage is real on **content-shaped work** — adding pages, restructuring collections, generating new layouts, building frontends. It doesn't make mikser better for non-content tasks (concurrency bugs in the worker pool, database tuning elsewhere in your stack); those are plain Node debugging like anywhere else. The visibility advantage also degrades past ~10k documents — at that scale the agent queries via the SDK instead of grepping the tree, which is still good but less "see everything at once."
62
66
 
63
67
  ## Plugins on top of the engine
@@ -80,7 +84,7 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
80
84
  | Plugin | What it does |
81
85
  |---|---|
82
86
  | `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
83
- | `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render |
87
+ | `api` | REST endpoints with sift-backed queries, per-endpoint tokens, optional render, opt-in [per-query disk cache](./documentation/caching.md) for reverse-proxy failover |
84
88
 
85
89
  **Integrations:**
86
90
 
@@ -111,7 +115,7 @@ The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a
111
115
 
112
116
  | Package | Framework | Notes |
113
117
  |---|---|---|
114
- | [`mikser-io-sdk-vue`](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) | Vue 3 | Composables, vue-router integration (`createMikserRouter`, `generateMikserRoutes`), provide/inject for the client. |
118
+ | [`mikser-io-sdk-vue`](https://github.com/almero-digital-marketing/mikser-io-sdk-vue) | Vue 3 | Composables, vue-router integration (`useMikserRoutes` to augment an existing router, `generateMikserRoutes` for SSG prerender), provide/inject for the client. |
115
119
  | [`mikser-io-sdk-react`](https://github.com/almero-digital-marketing/mikser-io-sdk-react) | React 18+ / 19+ | Hooks, `<MikserProvider>` Context, React Router v6+ integration via `useMikserRoutes` → `useRoutes()`. |
116
120
  | [`mikser-io-sdk-svelte`](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) | Svelte 5 (runes) | `$state` / `$effect` reactives, SvelteKit-friendly `generateMikserRoutes` for `entries()` prerender, `useMikserPages` for live nav. |
117
121
 
@@ -181,6 +185,7 @@ A few things this project takes seriously:
181
185
  | [Entities](./documentation/entities.md) | Users & Developers | Entity model, operations, journal, catalog |
182
186
  | [Rendering](./documentation/rendering.md) | Users & Developers | Render pipeline, render plugins, render modes |
183
187
  | [Watch Mode](./documentation/watch-mode.md) | Users | File watching, scheduled tasks, incremental builds |
188
+ | [Caching](./documentation/caching.md) | Users (production) | The `cache: true` disk cache + working nginx config for reverse-proxy failover |
184
189
  | [Architecture](./documentation/architecture.md) | Developers | Module-level reference — what's in each file |
185
190
  | [API Reference](./documentation/api-reference.md) | Developers | Complete public API reference |
186
191
  | [Decisions (ADRs)](./documentation/decisions/) | Developers | Load-bearing architectural choices and what protects them |
package/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { setup } from "./index.js"
2
3
 
3
4
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "6.23.1",
3
+ "version": "6.27.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path'
2
- import { access } from 'node:fs/promises'
2
+ import { access, writeFile, mkdir, rm } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
3
4
  import _ from 'lodash'
4
5
  import sift from 'sift'
5
6
  import { useRenderer, useCollection } from '../api.js'
@@ -118,6 +119,88 @@ async function runQuery({ filter, sort, fields, skip, limit, scope, findEntities
118
119
  return { items, total }
119
120
  }
120
121
 
122
+ // Derive the cache filename from the raw query string the client sent.
123
+ // no query string (`/entities`) → 'index'
124
+ // query string ('a=1&b=2') → 'a=1&b=2'
125
+ //
126
+ // We deliberately keep the raw, undecoded form because:
127
+ // - that's what nginx's `$args` variable contains
128
+ // - stock nginx can pass it straight into `try_files` without
129
+ // hashing modules or Lua
130
+ // - the URL is its own cache key — no extra contract for clients
131
+ // or proxies to agree on beyond "use the request URL"
132
+ //
133
+ // Trade-off: structurally-equal queries with different parameter
134
+ // orders produce different cache files (`?a=1&b=2` vs `?b=2&a=1`).
135
+ // Soft inefficiency, not a correctness bug — the SDK uses a stable
136
+ // order and most consumers are SDK-driven. Filesystem rules apply:
137
+ // most chars (`=`, `&`, `[`, `]`, `%`) are safe; 255-byte filename
138
+ // limits cap query length on typical fs. Document these in the
139
+ // caching README.
140
+ function cacheNameForQueryString(rawQueryString) {
141
+ if (!rawQueryString) return 'index'
142
+ return rawQueryString
143
+ }
144
+
145
+ // Wide-list defaults — tuned for "an SPA accidentally pulled the whole
146
+ // catalog because it forgot a `fields` projection." Operators can grep
147
+ // the warning out of logs; SDK users see the same shape client-side via
148
+ // the SDK's dev-mode warning.
149
+ const WIDE_RESPONSE_ITEMS = 100
150
+ const WIDE_RESPONSE_BYTES = 256 * 1024
151
+
152
+ function maybeWarnWide({ logger, endpoint, envelope, req }) {
153
+ const items = envelope?.items?.length ?? 0
154
+ if (items <= WIDE_RESPONSE_ITEMS) return
155
+ const bytes = Buffer.byteLength(JSON.stringify(envelope))
156
+ if (bytes <= WIDE_RESPONSE_BYTES && items <= WIDE_RESPONSE_ITEMS) return
157
+ const queryStr = (req.originalUrl || req.url || '').split('?')[1] || '(none)'
158
+ const sizeLabel = bytes >= 1024 * 1024
159
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
160
+ : `${Math.round(bytes / 1024)} KB`
161
+ logger.warn(
162
+ 'Api[%s] wide list response: %d items, %s — query=%s',
163
+ endpoint, items, sizeLabel, queryStr,
164
+ )
165
+ logger.warn(
166
+ ' ↳ Consider a `fields:` projection, or move this query to a `data.catalog.<name>` snapshot loaded via the SDK\'s `initialUrl`.',
167
+ )
168
+ }
169
+
170
+ // Write the response envelope to <out>/<base>/<endpoint>/entities/<name>.json.
171
+ // The path matches what nginx's `try_files /<base>/<endpoint>/entities/$args.json
172
+ // /<base>/<endpoint>/entities/index.json` looks up on upstream failure.
173
+ async function writeQueryCache({ outputFolder, base, name, cacheName, envelope, logger }) {
174
+ const relBase = base.replace(/^\//, '')
175
+ const dir = path.join(outputFolder, relBase, name, 'entities')
176
+ const file = path.join(dir, `${cacheName}.json`)
177
+ try {
178
+ await mkdir(dir, { recursive: true })
179
+ await writeFile(file, JSON.stringify(envelope), 'utf8')
180
+ logger.trace('Api[%s] cache write: %s (%d items)', name, file, envelope.items.length)
181
+ } catch (err) {
182
+ // Filename-length / illegal-char failures land here. Logged but
183
+ // not propagated — the live response is already on the wire.
184
+ logger.error('Api[%s] cache write failed (%s): %s', name, file, err.message)
185
+ }
186
+ }
187
+
188
+ // Clear the entire per-endpoint cache directory. Called when any
189
+ // catalog entity changes — coarse but correct, and on-demand writes
190
+ // rebuild the cache as queries come back in. The cost of warm-up
191
+ // after invalidation is bounded by traffic, which is what you'd want
192
+ // from a write-through cache anyway.
193
+ async function clearEndpointCache({ outputFolder, base, name, logger }) {
194
+ const relBase = base.replace(/^\//, '')
195
+ const dir = path.join(outputFolder, relBase, name, 'entities')
196
+ try {
197
+ await rm(dir, { recursive: true, force: true })
198
+ logger.trace('Api[%s] cache cleared: %s', name, dir)
199
+ } catch (err) {
200
+ logger.error('Api[%s] cache clear failed (%s): %s', name, dir, err.message)
201
+ }
202
+ }
203
+
121
204
  // MIME type lookup used when streaming a postprocessor's output back over
122
205
  // HTTP. The renderer's output extension lives on entity.destination
123
206
  // (assigned by the layouts plugin), so we use it as the source of truth.
@@ -211,6 +294,13 @@ export default ({
211
294
  findEntities,
212
295
  constants: { OPERATION },
213
296
  }) => {
297
+ // Shared between onLoaded (populates) and onFinalize (consumes).
298
+ // Hoisted so the lifecycle hooks see the same registry; the body
299
+ // inside onLoaded is what actually fills it in based on
300
+ // runtime.config.api.endpoints.
301
+ let apiBase = '/api'
302
+ const cachedEndpoints = []
303
+
214
304
  onLoaded(async () => {
215
305
  const logger = useLogger()
216
306
 
@@ -237,10 +327,14 @@ export default ({
237
327
  throw new Error('Express is required for the api plugin — run: npm install express')
238
328
  })
239
329
 
240
- const base = runtime.config.api?.base ?? '/api'
330
+ apiBase = runtime.config.api?.base ?? '/api'
331
+ const base = apiBase // alias so existing local references still work
241
332
  const globalPageSize = runtime.config.api?.pageSize ?? 10
242
333
  const globalRenderTimeout = runtime.config.api?.renderTimeout ?? 30_000
243
334
 
335
+ // cachedEndpoints is hoisted above (shared with onFinalize).
336
+ // Per-endpoint setup loop pushes into it when cache: true is set.
337
+
244
338
  // The plugin mirrors the vector / data plugin shape: a named map
245
339
  // of endpoints, each with its own optional `token`, `query`
246
340
  // scope, allowed `operations`, and overrides for pageSize /
@@ -267,6 +361,69 @@ export default ({
267
361
  const pageSize = ep.pageSize ?? globalPageSize
268
362
  const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
269
363
 
364
+ // Server-enforced field projection. When set, every list /
365
+ // query / subscribe response is narrowed to exactly these
366
+ // dotted paths — regardless of what the client asks for.
367
+ //
368
+ // The right tool when an endpoint backs a broad list query
369
+ // and only a handful of fields per entity are actually
370
+ // useful to the client — sitemap, navigation menus,
371
+ // faceted search dimensions. A SPA's first paint shouldn't
372
+ // download every markdown body just to find out which
373
+ // routes exist.
374
+ //
375
+ // Skip it for endpoints that serve individual full
376
+ // documents (the typical `public` shape used by
377
+ // useDocument(id)) — those queries return one entity at a
378
+ // time so narrowing has no real benefit.
379
+ const allowedFields = Array.isArray(ep.fields) && ep.fields.length
380
+ ? ep.fields
381
+ : null
382
+
383
+ // Pick the effective projection for a request. If the
384
+ // endpoint declares allowedFields, that's the ceiling: a
385
+ // client requesting more gets only the intersection. A
386
+ // client requesting nothing gets exactly allowedFields.
387
+ // No allowedFields → whatever the client asked for (or
388
+ // all fields when omitted).
389
+ function resolveFields(requested) {
390
+ if (!allowedFields) return requested ?? null
391
+ if (!requested || !requested.length) return allowedFields
392
+ const allowSet = new Set(allowedFields)
393
+ return requested.filter(f => allowSet.has(f))
394
+ }
395
+
396
+ // Endpoints with cache: true cache GET /entities responses
397
+ // to disk on a per-query-string basis. Path scheme:
398
+ // <out>/<base>/<name>/entities/<raw-query-string>.json
399
+ // <out>/<base>/<name>/entities/index.json (no params)
400
+ //
401
+ // On any catalog change, the whole cache directory is
402
+ // dropped — the next requests through repopulate whatever's
403
+ // needed. Coarse but correct, no per-query tracking.
404
+ //
405
+ // Reverse-proxy failover (stock nginx, no Lua):
406
+ // location /api/sitemap/entities {
407
+ // proxy_pass http://localhost:3001;
408
+ // proxy_intercept_errors on;
409
+ // error_page 502 503 504 = @cache;
410
+ // }
411
+ // location @cache {
412
+ // root /var/www/out;
413
+ // try_files /api/sitemap/entities/$args.json
414
+ // /api/sitemap/entities/index.json
415
+ // =502;
416
+ // }
417
+ //
418
+ // POST /entities/query responses aren't cached — there's no
419
+ // URL the proxy could derive a cache file path from. Use
420
+ // GET for cacheable queries (the SDK's client.list({...}) →
421
+ // GET URL is the canonical pattern).
422
+ if (ep.cache === true) {
423
+ cachedEndpoints.push({ name })
424
+ }
425
+ const cacheEnabled = ep.cache === true
426
+
270
427
  const auth = (req, res, next) => {
271
428
  if (!ep.token) return next()
272
429
  if (req.headers.authorization === `Bearer ${ep.token}`) return next()
@@ -298,7 +455,7 @@ export default ({
298
455
  const { items, total } = await runQuery({
299
456
  filter: parsed.filter,
300
457
  sort: parsed.sort,
301
- fields: parsed.fields,
458
+ fields: resolveFields(parsed.fields),
302
459
  skip,
303
460
  limit,
304
461
  scope: query,
@@ -307,12 +464,35 @@ export default ({
307
464
 
308
465
  const page = Math.floor(skip / limit) + 1
309
466
  const totalPages = Math.ceil(total / limit) || 1
310
- res.json({
467
+ const envelope = {
311
468
  items, page, limit, total, totalPages,
312
469
  hasNext: skip + limit < total,
313
470
  hasPrev: skip > 0,
314
- })
471
+ }
472
+ res.json(envelope)
315
473
  logger.trace('Api[%s] list %dms (%d/%d items)', name, Date.now() - t0, items.length, total)
474
+ maybeWarnWide({ logger, endpoint: name, envelope, req })
475
+
476
+ // Write-through cache: write the response to a file
477
+ // path that mirrors the request URL — same `$args`
478
+ // nginx's `try_files` sees, no hashing on either
479
+ // side. See the caching docs for the nginx config
480
+ // that wires the failover.
481
+ if (cacheEnabled && runtime.options.outputFolder) {
482
+ const url = req.originalUrl || req.url || ''
483
+ const qIdx = url.indexOf('?')
484
+ const rawQueryString = qIdx >= 0 ? url.slice(qIdx + 1) : ''
485
+ const cacheName = cacheNameForQueryString(rawQueryString)
486
+ // Fire-and-forget — the response is already sent.
487
+ writeQueryCache({
488
+ outputFolder: runtime.options.outputFolder,
489
+ base: apiBase,
490
+ name,
491
+ cacheName,
492
+ envelope,
493
+ logger,
494
+ }).catch(() => {})
495
+ }
316
496
  } catch (err) {
317
497
  logger.error('Api[%s] list error (%dms): %s', name, Date.now() - t0, err.message)
318
498
  res.status(500).json({ error: err.message })
@@ -330,19 +510,31 @@ export default ({
330
510
  const skip = rawSkip ?? (Math.max(1, parseInt(rawPage) || 1) - 1) * limit
331
511
 
332
512
  const { items, total } = await runQuery({
333
- filter, sort, fields, skip, limit,
513
+ filter, sort,
514
+ fields: resolveFields(fields),
515
+ skip, limit,
334
516
  scope: query,
335
517
  findEntities,
336
518
  })
337
519
 
338
520
  const page = Math.floor(skip / limit) + 1
339
521
  const totalPages = Math.ceil(total / limit) || 1
340
- res.json({
522
+ const envelope = {
341
523
  items, page, limit, total, totalPages,
342
524
  hasNext: skip + limit < total,
343
525
  hasPrev: skip > 0,
344
- })
526
+ }
527
+ res.json(envelope)
345
528
  logger.trace('Api[%s] query %dms (%d/%d items)', name, Date.now() - t0, items.length, total)
529
+ maybeWarnWide({ logger, endpoint: name, envelope, req })
530
+
531
+ // POST queries aren't disk-cached. The reverse proxy
532
+ // failover scheme is URL-based (cache file path =
533
+ // request URL), but POST has no URL-equivalent for
534
+ // its body. Cacheable queries should use GET — which
535
+ // the SDK's list() does by default. Body-only queries
536
+ // are by definition complex/non-canonicalizable so
537
+ // they're treated as live-only.
346
538
  } catch (err) {
347
539
  logger.error('Api[%s] query error (%dms): %s', name, Date.now() - t0, err.message)
348
540
  res.status(500).json({ error: err.message })
@@ -377,6 +569,7 @@ export default ({
377
569
  endpointName: name,
378
570
  scope: query,
379
571
  filter: filterFn,
572
+ allowedFields,
380
573
  res,
381
574
  })
382
575
 
@@ -455,27 +648,63 @@ export default ({
455
648
  // before plugin onFinalized hooks. By Finalize we still have the
456
649
  // cycle's journal entries; by Finalized they're already gone.
457
650
  onFinalize(async (signal) => {
458
- if (!subscriptions.size) return
459
651
  const logger = useLogger()
460
652
  const evMap = {
461
653
  [OPERATION.CREATE]: 'create',
462
654
  [OPERATION.UPDATE]: 'update',
463
655
  [OPERATION.DELETE]: 'delete',
464
656
  }
657
+
658
+ // Cache invalidation is intentionally coarse: if ANY entity
659
+ // changed in this cycle, rebuild every cached endpoint. Per-
660
+ // endpoint scope matching would shave a few file writes off a
661
+ // churning catalog but adds bug surface for no real win —
662
+ // buildDefaultEnvelope is microseconds (in-memory sift),
663
+ // writeFile is milliseconds. Simpler and safer to just rebuild.
664
+ let anyChange = false
665
+
666
+ // Single journal iteration drives both SSE push and the
667
+ // any-change flag — one pass per cycle.
465
668
  for await (const { operation, entity } of useJournal(
466
669
  'Api subscriptions',
467
670
  [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE],
468
671
  signal,
469
672
  )) {
673
+ anyChange = true
674
+
675
+ // SSE push to live subscribers
470
676
  for (const [subId, sub] of subscriptions) {
471
677
  if (sub.scope && !sub.scope(entity)) continue
472
678
  if (sub.filter && !sub.filter(entity)) continue
679
+ // Apply the endpoint's field projection to SSE payloads
680
+ // too — so live updates leak no more than list/query
681
+ // responses do.
682
+ const projected = sub.allowedFields
683
+ ? _.pick(entity, sub.allowedFields)
684
+ : entity
473
685
  const payload = operation === OPERATION.DELETE
474
686
  ? { id: entity.id }
475
- : { id: entity.id, entity }
687
+ : { id: entity.id, entity: projected }
476
688
  sseSend(sub.res, evMap[operation], payload)
477
689
  logger.trace('Api subscription %s %s: %s', subId, evMap[operation], entity.id)
478
690
  }
479
691
  }
692
+
693
+ // Clear each cached endpoint's directory on any entity change.
694
+ // Subsequent list requests re-warm the cache via the write-
695
+ // through path in the GET/POST handlers above. Coarse but
696
+ // correct — any stale entry, anywhere in the per-endpoint
697
+ // cache, gets dropped without us having to track which queries
698
+ // were affected by which entity changes.
699
+ if (anyChange && cachedEndpoints.length > 0 && runtime.options.outputFolder) {
700
+ for (const ep of cachedEndpoints) {
701
+ await clearEndpointCache({
702
+ outputFolder: runtime.options.outputFolder,
703
+ base: apiBase,
704
+ name: ep.name,
705
+ logger,
706
+ })
707
+ }
708
+ }
480
709
  })
481
710
  }