mikser-io 8.0.0 → 8.2.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
@@ -79,7 +79,7 @@ The honest caveat: this advantage is real on **content-shaped work** — adding
79
79
 
80
80
  ## Control mikser from your AI agent
81
81
 
82
- Add `--mcp` to your mikser command and any MCP-speaking client — Claude Desktop, Claude Code, ChatGPT, custom agents — connects to the running engine. From inside a chat, your AI can:
82
+ Install the [`mikser-io-mcp`](https://github.com/almero-digital-marketing/mikser-io-mcp) plugin and any MCP-speaking client — Claude Desktop, Claude Code, ChatGPT, custom agents — connects to the running engine. From inside a chat, your AI can:
83
83
 
84
84
  - read every entity in the catalog
85
85
  - write new content files (markdown, layouts, configuration) — writes land on disk and the next cycle picks them up
@@ -90,8 +90,16 @@ Add `--mcp` to your mikser command and any MCP-speaking client — Claude Deskto
90
90
 
91
91
  Plugins extend the tool surface the same way they mount HTTP routes; install the plugin, the agent gets new verbs. No glue code, no per-project agent wiring.
92
92
 
93
+ ```js
94
+ // mikser.config.js
95
+ export default {
96
+ plugins: ['mcp', /* … */],
97
+ // optional: mcp: { path: '/mcp', endpoints: { … } }
98
+ }
99
+ ```
100
+
93
101
  ```bash
94
- mikser --server --mcp # mounts MCP at /mcp on the same port as --server
102
+ mikser --server # MCP mounts at /mcp on the same port
95
103
  ```
96
104
 
97
105
  What that feels like in practice: *"draft three hero-section variants and show me previews"* — three layouts written, three previews returned inline, one chat turn. *"Why did the build break?"* — the agent reads the rolling log buffer and answers from the same view your terminal sees. *"Update this article's tone and show me the preview"* — the agent edits the file and surfaces the rendered article inline; you click Approve or Reject, the agent acts on your choice. Operator, AI, and any observer dashboard share the same engine because mikser is single-tenant by design.
@@ -109,7 +117,7 @@ When an AI agent edits ten files, the next question is: *did it do what I asked?
109
117
 
110
118
  The shift this enables: AI review stops being *"read every change"* and becomes *"spot-check the agent's confidence."* The agent verifies its own work; the human samples and approves. That's the workflow that lets a content team actually use AI at scale — change the tone across the entire site in a morning, ship it after a coffee.
111
119
 
112
- Full tool reference and twelve worked scenarios in [MCP — talking to mikser from AI](./documentation/mcp.md).
120
+ Full tool reference and twelve worked scenarios in the [`mikser-io-mcp` plugin docs](https://github.com/almero-digital-marketing/mikser-io-mcp#readme).
113
121
 
114
122
  ## Plugins on top of the engine
115
123
 
@@ -132,14 +140,14 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
132
140
  |---|---|
133
141
  | `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
134
142
  | `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 |
135
- | `preview` | In-memory render cache + `GET /preview/:filename` route. Companion to the [`mikser_preview`](./documentation/mcp.md) MCP tool — transient render bytes served at a clickable URL, no filesystem footprint |
143
+ | `preview` | In-memory render cache + `GET /preview/:filename` route. Companion to the `mikser_preview_render` MCP tool (in [`mikser-io-mcp`](https://github.com/almero-digital-marketing/mikser-io-mcp)) — transient render bytes served at a clickable URL, no filesystem footprint |
136
144
 
137
145
  **Integrations:**
138
146
 
139
147
  | Plugin | What it does |
140
148
  |---|---|
141
149
  | [`mikser-io-vector`](https://github.com/almero-digital-marketing/mikser-io-vector) | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
142
- | [`mikser-io-plugin-schemas`](https://github.com/almero-digital-marketing/mikser-io-plugin-schemas) | Zod-backed entity validation + auto-generated TypeScript declarations for the SDK. Auto-detects `$`-keyed references and warns on broken ones — see [ADR-0007](./documentation/decisions/0007-references-declaration-and-expansion.md) |
150
+ | [`mikser-io-schemas`](https://github.com/almero-digital-marketing/mikser-io-schemas) | Zod-backed entity validation + auto-generated TypeScript declarations for the SDK. Auto-detects `$`-keyed references and warns on broken ones — see [ADR-0007](./documentation/decisions/0007-references-declaration-and-expansion.md) |
143
151
  | [`mikser-io-archive`](https://github.com/almero-digital-marketing/mikser-io-archive) | Persist matching entities to YAML — audit trail, versioned content history, downstream export |
144
152
  | `mapper` | Run config-supplied transforms over matched entities each cycle (in-core, generic transformation layer) |
145
153
  | [`mikser-io-live`](https://github.com/almero-digital-marketing/mikser-io-live) | Lightweight dev server with browser auto-refresh — pair with `--watch` for the classic save→reload loop |
@@ -162,7 +170,7 @@ The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a
162
170
  | [`mikser-io-sdk-api`](https://github.com/almero-digital-marketing/mikser-io-sdk-api) | `api` | `entities(name).list / query / urlFor / pages / update / delete / render / live` — Mongo-style filter operators backed by sift, sort, projection, pagination, SSE-driven live subscriptions, and `expand: [...]` to inline-resolve `$`-keyed references in one round-trip (multi-hop chains, `*` array iteration) |
163
171
  | [`mikser-io-sdk-vector`](https://github.com/almero-digital-marketing/mikser-io-sdk-vector) | `vector` | `vector(storeName).findSimilar(text, { limit })` — semantic search hits with the original mapped object attached |
164
172
 
165
- **Framework integrations** — all three wrap `mikser-io-sdk-api` in framework-idiomatic shapes. Same surface: `useDocument` / `useDocuments` live data, multilingual `useHref` / `useAlternates`, asset resolution via `useAsset`, generic on entity type so `mikser-io-plugin-schemas`-emitted types compose:
173
+ **Framework integrations** — all three wrap `mikser-io-sdk-api` in framework-idiomatic shapes. Same surface: `useDocument` / `useDocuments` live data, multilingual `useHref` / `useAlternates`, asset resolution via `useAsset`, generic on entity type so `mikser-io-schemas`-emitted types compose:
166
174
 
167
175
  | Package | Framework | Notes |
168
176
  |---|---|---|
@@ -170,7 +178,7 @@ The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a
170
178
  | [`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()`. |
171
179
  | [`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. |
172
180
 
173
- Each SDK ships TypeScript declarations so client projects get autocomplete on filters, envelopes, and the `MikserError` thrown on non-2xx responses. Pair any of the framework SDKs with the `entities.d.ts` emitted by `mikser-io-plugin-schemas` for typed entity meta per layout. Install only the one(s) a project needs.
181
+ Each SDK ships TypeScript declarations so client projects get autocomplete on filters, envelopes, and the `MikserError` thrown on non-2xx responses. Pair any of the framework SDKs with the `entities.d.ts` emitted by `mikser-io-schemas` for typed entity meta per layout. Install only the one(s) a project needs.
174
182
 
175
183
  ## Quick Start
176
184
 
@@ -201,7 +209,7 @@ The shape mikser fits cleanly:
201
209
 
202
210
  - **Marketing sites with editorial teams** — content authors work in files (via their editor, a Git client, or `mikser-io-decap`), engineers ship features without negotiating with a CMS schema, the site stays portable.
203
211
  - **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern in `sdk-vue` decouples logical references from per-locale URLs. One source tree, many language deployments.
204
- - **Content-heavy product catalogues** — `documents` + `mikser-io-plugin-schemas` + `data` plugin + a Vue frontend = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
212
+ - **Content-heavy product catalogues** — `documents` + `mikser-io-schemas` + `data` plugin + a Vue frontend = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
205
213
  - **AI-augmented media pipelines** — `assets` plugin presets call out to Replicate / OpenAI / local models to upscale images, transcribe audio, transcode video. The pipeline is JS code, so anything Node can do is in scope.
206
214
  - **Mixed-output publishing** — the same source document renders to HTML, PDF (via `post-pdf`), MJML email (via `post-mjml`), and JSON snapshots. One catalog, many output formats, all concurrent.
207
215
  - **Headless backends for static frontends** — pair the `api` plugin with `sdk-api` for SSE-driven live frontends; pair the `data` plugin output with any static host for pre-rendered consumption.
@@ -235,7 +243,7 @@ Mikser itself has a previous chapter: the [legacy 7.x line](https://github.com/a
235
243
  | [Entities](./documentation/entities.md) | Users & Developers | Entity model, operations, journal, catalog |
236
244
  | [Rendering](./documentation/rendering.md) | Users & Developers | Render pipeline, render plugins, render modes |
237
245
  | [Watch Mode](./documentation/watch-mode.md) | Users | File watching, scheduled tasks, incremental builds |
238
- | [MCP](./documentation/mcp.md) | Users | The `--mcp` server — tool surface, `mikser://` resources, twelve worked AI-driven scenarios |
246
+ | [MCP](https://github.com/almero-digital-marketing/mikser-io-mcp#readme) | Users | The `mikser-io-mcp` plugin — tool surface, `mikser://` resources, twelve worked AI-driven scenarios |
239
247
  | [Caching](./documentation/caching.md) | Users (production) | The `cache: true` disk cache + working nginx config for reverse-proxy failover |
240
248
  | [Architecture](./documentation/architecture.md) | Developers | Module-level reference — what's in each file |
241
249
  | [API Reference](./documentation/api-reference.md) | Developers | Complete public API reference |
package/index.js CHANGED
@@ -5,11 +5,11 @@ export * from './src/journal.js'
5
5
  export * from './src/lifecycle.js'
6
6
  export * from './src/catalog.js'
7
7
  export * from './src/refs.js'
8
+ export * from './src/subscriptions.js'
8
9
  export * from './src/config.js'
9
10
  export * from './src/plugins.js'
10
11
  export * from './src/manager.js'
11
- export * from './src/tracking.js'
12
+ export * from './src/logger.js'
12
13
  export * from './src/engine.js'
13
- export * from './src/api.js'
14
- export * from './src/source.js'
15
- export * from './src/mcp.js'
14
+ export * from './src/render.js'
15
+ export * from './src/source.js'
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "8.0.0",
3
+ "version": "8.2.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": {
7
- "debug": "node --no-warnings app.js --debug --server --watch --working-folder test/fixture",
7
+ "debug": "node --no-warnings app.js --server --watch --working-folder test/fixture",
8
8
  "test:unit": "node --test --test-reporter=spec 'test/unit/**/*.test.js'",
9
9
  "test:smoke": "node --no-warnings app.js --working-folder test/fixture",
10
+ "test:perf": "node test/perf/generate.js && node --no-warnings app.js --working-folder test/perf",
10
11
  "test": "npm run test:unit && npm run test:smoke"
11
12
  },
12
13
  "bin": {
@@ -21,22 +22,21 @@
21
22
  "license": "ISC",
22
23
  "dependencies": {
23
24
  "@budibase/handlebars-helpers": "^0.14.3",
24
- "@modelcontextprotocol/sdk": "^1.29.0",
25
25
  "await-semaphore": "^0.1.3",
26
26
  "axios": "^1.17.0",
27
27
  "chokidar": "^5.0.0",
28
- "cli-progress": "^3.12.0",
29
28
  "commander": "^15.0.0",
29
+ "cors": "^2.8.5",
30
30
  "dayjs": "^1.11.21",
31
31
  "deepdash": "^5.3.9",
32
32
  "escape-string-regexp": "^5.0.0",
33
33
  "execa": "^9.6.1",
34
34
  "front-matter": "^4.0.2",
35
+ "gauge": "^5.0.2",
35
36
  "globby": "^16.2.0",
36
37
  "handlebars": "^4.7.9",
37
38
  "hasha": "^7.0.0",
38
39
  "is-url": "^1.2.4",
39
- "knex": "^3.2.10",
40
40
  "line-reader": "^0.4.0",
41
41
  "lodash": "^4.18.1",
42
42
  "lowdb": "^7.0.1",
@@ -48,7 +48,6 @@
48
48
  "pino-pretty": "^13.1.3",
49
49
  "piscina": "^5.1.4",
50
50
  "sift": "^17.1.3",
51
- "sqlite3": "^6.0.1",
52
51
  "truncate-stream": "^1.0.2",
53
52
  "yaml": "^2.9.0"
54
53
  },
package/src/catalog.js CHANGED
@@ -7,8 +7,8 @@ import { Low } from 'lowdb'
7
7
  import path from 'node:path'
8
8
  import { JSONFile } from 'lowdb/node'
9
9
  import _ from 'lodash'
10
-
11
- let catalog
10
+ import sift from 'sift'
11
+ import { expandEntity, projectMeta } from './utils.js'
12
12
 
13
13
  // Same reasoning as journal.js — initialize the catalog in
14
14
  // onInitialized so every plugin hook from onLoad onwards can safely
@@ -16,9 +16,14 @@ let catalog
16
16
  // catalog.js imports useJournal from journal.js, so journal.js's
17
17
  // onInitialized registers first within the phase — its hook runs
18
18
  // before this one.
19
+ //
20
+ // The catalog lives at `runtime.catalog` — single source of truth.
21
+ // All operations below read through that property so tests can stub
22
+ // `runtime.catalog` with an in-memory equivalent and the same call
23
+ // shapes work without code changes.
19
24
  onInitialized(async () => {
20
25
  const adapter = new JSONFile(path.join(runtime.options.runtimeFolder, `catalog.json`))
21
- catalog = new Low(adapter, {
26
+ const catalog = new Low(adapter, {
22
27
  entities: [],
23
28
  })
24
29
  catalog.chain = _.chain(catalog).get('data')
@@ -27,6 +32,7 @@ onInitialized(async () => {
27
32
 
28
33
  onPersist(async () => {
29
34
  const logger = useLogger()
35
+ const catalog = runtime.catalog
30
36
  for await (let { operation, entity } of useJournal('Catalog')) {
31
37
  switch (operation) {
32
38
  case OPERATION.CREATE:
@@ -57,17 +63,169 @@ onPersist(async () => {
57
63
  })
58
64
 
59
65
  onFinalized(async () => {
60
- await catalog.write()
66
+ await runtime.catalog.write()
61
67
  })
62
68
 
63
69
  export async function findEntity(query) {
64
70
  if (!query) return
65
- return catalog.chain.get('entities').find(query).value()
71
+ return runtime.catalog.chain.get('entities').find(query).value()
66
72
  }
67
73
 
68
74
  export async function findEntities(query) {
69
75
  if (!query) {
70
- return catalog.chain.get('entities').value()
76
+ return runtime.catalog.chain.get('entities').value()
77
+ }
78
+ return runtime.catalog.chain.get('entities').filter(query).value()
79
+ }
80
+
81
+ // High-level CRUD-with-query surface — sift filters, sort, pagination,
82
+ // dotted-path projection, plus optional inline-expand of $-keyed
83
+ // references (ADR-0007). Used by the api plugin's HTTP handlers, the
84
+ // mikser-io-mcp plugin's tools, and any library-mode caller.
85
+ //
86
+ // `findEntity`/`findEntities` above are raw catalog access (no filter,
87
+ // no pagination, callback-style query). The queryEntities / readEntity
88
+ // pair below are the higher-level operations that compose findEntities
89
+ // with sift + expand + projection.
90
+ //
91
+ // Lives in catalog.js (not in the api plugin) because catalog operations
92
+ // are engine-level: render workers, library embedders, and other plugins
93
+ // all need to query the catalog with the same semantics. The api plugin
94
+ // is one consumer that adds HTTP routing + an on-disk cache on top.
95
+
96
+ // Match the same heuristic the schemas plugin uses for ref-existence
97
+ // checks — id, meta.href, or stripped-extension id. Centralised so the
98
+ // expand walker resolves refs the same way everywhere.
99
+ async function findRef(ref) {
100
+ if (!ref || typeof ref !== 'string') return null
101
+ const matches = await findEntities(e =>
102
+ !!e && (
103
+ e.id === ref ||
104
+ e.meta?.href === ref ||
105
+ (typeof e.id === 'string' && e.id.replace(/\.[^./]+$/, '') === ref)
106
+ ),
107
+ )
108
+ return matches[0] ?? null
109
+ }
110
+
111
+ // Per-call expansion caps. Plumbed from mikser.config.js:
112
+ // catalog: { expand: { maxDepth, maxPaths, maxResolved } }
113
+ // Defaults match ADR-0007 B7. Centralised here so every expand-aware
114
+ // operation reads the same numbers. Private — callers reach it via
115
+ // `assertExpand` (pre-flight validation) or via queryEntities (which
116
+ // applies the same caps inside the walker).
117
+ function expandLimits() {
118
+ const cfg = runtime?.config?.catalog?.expand ?? {}
119
+ return {
120
+ maxDepth: typeof cfg.maxDepth === 'number' ? cfg.maxDepth : 5,
121
+ maxPaths: typeof cfg.maxPaths === 'number' ? cfg.maxPaths : 20,
122
+ maxResolved: typeof cfg.maxResolved === 'number' ? cfg.maxResolved : 100,
123
+ }
124
+ }
125
+
126
+ // Validate an `expand` spec against the configured caps. Throws on
127
+ // violation; returns nothing on success. Used by `subscribe()` at
128
+ // registration time to reject bad expand before opening any transport
129
+ // channels. The same caps are enforced inside the walker, so this is
130
+ // purely about early/clean error surfacing.
131
+ export function assertExpand(expand) {
132
+ if (!expand?.length) return
133
+ const { maxPaths, maxDepth } = expandLimits()
134
+ if (expand.length > maxPaths) {
135
+ throw new Error(
136
+ `expand has ${expand.length} paths, exceeds maxPaths (${maxPaths})`,
137
+ )
138
+ }
139
+ for (const p of expand) {
140
+ const parts = p.split('.').filter(Boolean)
141
+ if (parts.length > maxDepth) {
142
+ throw new Error(
143
+ `Path '${p}' has length ${parts.length}, exceeds maxDepth (${maxDepth})`,
144
+ )
145
+ }
146
+ }
147
+ }
148
+
149
+ // Apply expand-then-project to one entity. When `expand` has entries
150
+ // the walker inlines resolved refs first; in all cases the final meta
151
+ // is normalized (`$`-keys stripped) so the wire shape matches what the
152
+ // SDK consumers and templates expect per ADR-0007 A3. Private — the
153
+ // only callers are queryEntities (per-item) and readEntity (via
154
+ // queryEntities). External code expands by going through one of those.
155
+ async function expandAndProject(entity, expand) {
156
+ let result = entity
157
+ if (expand?.length) {
158
+ result = await expandEntity(entity, expand, {
159
+ findRef,
160
+ ...expandLimits(),
161
+ })
71
162
  }
72
- return catalog.chain.get('entities').filter(query).value()
163
+ if (!result?.meta) return result
164
+ return { ...result, meta: projectMeta(result.meta) }
165
+ }
166
+
167
+ // Paginated, sorted, filtered, projected query over the catalog. The
168
+ // `scope` arg is an optional pre-filter predicate (used by the api
169
+ // plugin's per-endpoint allowedEntities lambda); library callers
170
+ // usually pass nothing. Returns `{ items, total, skip, limit, hasNext }`.
171
+ export async function queryEntities({
172
+ filter, sort, fields, skip, limit, expand, scope,
173
+ } = {}) {
174
+ const effectiveLimit = Math.min(100, Math.max(1, limit ?? 25))
175
+ const effectiveSkip = Math.max(0, skip ?? 0)
176
+
177
+ let all = await findEntities()
178
+ if (scope) all = all.filter(scope)
179
+
180
+ if (filter && Object.keys(filter).length) {
181
+ const match = sift(filter)
182
+ all = all.filter(match)
183
+ }
184
+
185
+ const total = all.length
186
+
187
+ if (sort && Object.keys(sort).length) {
188
+ const entries = Object.entries(sort)
189
+ all.sort((a, b) => {
190
+ for (const [key, dir] of entries) {
191
+ const av = _.get(a, key)
192
+ const bv = _.get(b, key)
193
+ if (av == null && bv == null) continue
194
+ if (av == null) return 1
195
+ if (bv == null) return -1
196
+ if (av < bv) return -dir
197
+ if (av > bv) return dir
198
+ }
199
+ return 0
200
+ })
201
+ }
202
+
203
+ let items = all.slice(effectiveSkip, effectiveSkip + effectiveLimit)
204
+ items = await Promise.all(items.map(item => expandAndProject(item, expand)))
205
+ if (fields?.length) items = items.map(e => _.pick(e, fields))
206
+
207
+ return {
208
+ items,
209
+ total,
210
+ skip: effectiveSkip,
211
+ limit: effectiveLimit,
212
+ hasNext: effectiveSkip + effectiveLimit < total,
213
+ }
214
+ }
215
+
216
+ // Read a single entity by catalog id. Returns the entity (post-expand,
217
+ // post-project) or null when not found. Throws on missing id.
218
+ // Source-file content loading is a separate concern — compose with
219
+ // `readEntityContent` from utils.js if you want `entity.content`
220
+ // populated (with the text/binary gate) or call `readFile(entity.uri)`
221
+ // directly if you want raw bytes.
222
+ export async function readEntity({ id, expand } = {}) {
223
+ if (!id) throw new Error('id is required')
224
+ const result = await queryEntities({
225
+ filter: { id },
226
+ skip: 0,
227
+ limit: 1,
228
+ expand,
229
+ })
230
+ return result.items[0] ?? null
73
231
  }
package/src/constants.js CHANGED
@@ -13,8 +13,30 @@ export const ACTION = {
13
13
  TRIGGER: 'trigger',
14
14
  }
15
15
 
16
+ // Render / postprocess dispatch modes. Set per-entity via
17
+ // `meta.task: 'inline' | 'serial' | 'worker'`, or per-task by a plugin.
18
+ //
19
+ // INLINE — runs the render/postprocess in the main event loop via
20
+ // `await`. The outer dispatcher iterates with concurrency =
21
+ // runtime.options.threads (default 4), so multiple inline
22
+ // tasks can be in-flight at once; but they share the main
23
+ // thread, so CPU-bound work doesn't actually run in
24
+ // parallel. Right default for cheap renders (HTML / md /
25
+ // yaml templating) where IPC overhead would dominate.
26
+ //
27
+ // SERIAL — runs in the main event loop too, but via p-queue with
28
+ // concurrency 1. One at a time, no interleaving. Right
29
+ // choice for tasks that touch a shared, non-reentrant
30
+ // resource (a single browser instance, a sequential
31
+ // encoder, etc.).
32
+ //
33
+ // WORKER — runs on a real OS thread via the Piscina pool sized to
34
+ // runtime.options.threads. True parallelism. Right choice
35
+ // for expensive CPU-bound renders (PDF via headless
36
+ // Chromium, MJML compilation, image compose) where the
37
+ // render cost amortizes the IPC overhead.
16
38
  export const TASKS = {
17
- QUEUE: 'queue',
39
+ INLINE: 'inline',
40
+ SERIAL: 'serial',
18
41
  WORKER: 'worker',
19
- POOL: 'pool',
20
42
  }