mikser-io 6.23.0 → 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
@@ -4,9 +4,13 @@
4
4
 
5
5
  # Mikser
6
6
 
7
- **Mikser is the content layer of your application.** Business logic, user accounts, transactions live in their own services; mikser handles the parts that *are* content — pages, docs, the published catalog, multi-format outputs. The SDKs are the seam between them.
7
+ **Mikser is the content layer of your application.** Business logic, user accounts, transactions live in their own services; mikser handles the parts that *are* content — pages, docs, the published catalog, multi-format outputs. [Vue](https://github.com/almero-digital-marketing/mikser-io-sdk-vue), [React](https://github.com/almero-digital-marketing/mikser-io-sdk-react), and [Svelte](https://github.com/almero-digital-marketing/mikser-io-sdk-svelte) SDKs are the seam between them — same surface across all three (`useDocument`, `useDocuments`, multilingual `useHref`, live SSE updates), each in its framework's idiomatic shape.
8
8
 
9
- Built for Node.js around a strict lifecycle, a composable plugin system, and direct control over every output. Every document, asset, and template flows through the same deterministic pipeline. Plugins hook in at any phase; nothing runs outside the cycle. It scales from a single markdown blog to a multi-language, multi-format publishing platform — and stays predictable in both directions.
9
+ Built for Node.js around a strict lifecycle, a composable plugin system, and direct control over every output. Every document, asset, and template flows through the same deterministic pipeline. Plugins hook in at any phase; nothing runs outside the cycle. It scales from a single markdown blog to a multi-language, multi-format publishing platform with image / video / AI pipelines, live SSE-driven editors, semantic search, and typed frontend contracts — and stays predictable in both directions.
10
+
11
+ It's MIT-licensed, runs on Node 18+, has zero hosted dependencies, and the entire content tree it manages is a folder of `.md` and `.yml` files you can copy, diff, and version-control. **The portability promise is the architecture, not a feature.**
12
+
13
+ > **New to mikser?** Read the [Architecture Overview](./documentation/overview.md) — one document, end-to-end walkthrough of how a file becomes a deployed page across all twenty lifecycle phases. It's the doc most projects need first.
10
14
 
11
15
  ## Where it fits
12
16
 
@@ -30,10 +34,14 @@ Build mikser into the parts of your application that are content-shaped. Keep th
30
34
 
31
35
  **Concurrent rendering.** Renders fan out across a worker pool that keeps every CPU core hot. Multi-format outputs (HTML, PDF, MJML email, etc.) generate in parallel from the same source.
32
36
 
37
+ **Image, video, and AI pipelines authored as plugins — not configured.** The `assets` plugin runs user-written preset modules over binary inputs. A preset is a plain Node module: ~10 lines around `sharp` resize an image, ~10 lines around `fluent-ffmpeg` transcode a video, ~30 lines around the Replicate API run an AI upscaler. The pipeline isn't a fixed menu of operations — it's whatever Node can call. Most SSGs cap your asset processing at "resize and convert format." Mikser caps it at "what can Node do." Compose with the `resources` plugin and your DAM, CDN, or company content server becomes an upstream input — uploaded files end up transcoded, watermarked, AI-enhanced, and deployed without manual handoff. See [the assets / resources docs](./documentation/plugins.md#assets) for end-to-end examples.
38
+
33
39
  **One lifecycle, everything composes.** Plugins hook into 20+ named lifecycle phases. A search-indexing plugin shares the same journal iteration as an email-rendering plugin and a PDF-postprocessing plugin — no glue code, no orchestration layer. The engine doesn't know which plugins are loaded; plugins don't have to know about each other.
34
40
 
35
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.
36
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
+
37
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.
38
46
 
39
47
  **Open source.** MIT-licensed, on GitHub, no telemetry, no auth wall, no SaaS dependency. What you see is what runs.
@@ -52,6 +60,8 @@ Files-as-source isn't just a portability story — it makes the project unusuall
52
60
 
53
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.
54
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
+
55
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."
56
66
 
57
67
  ## Plugins on top of the engine
@@ -74,13 +84,14 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
74
84
  | Plugin | What it does |
75
85
  |---|---|
76
86
  | `data` | JSON snapshots of entities / context / catalog, written to disk for static serving |
77
- | `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 |
78
88
 
79
89
  **Integrations:**
80
90
 
81
91
  | Plugin | What it does |
82
92
  |---|---|
83
- | `vector` | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
93
+ | [`mikser-io-vector`](https://github.com/almero-digital-marketing/mikser-io-vector) | OpenAI embeddings + semantic search (sqlite-vec or pgvector) |
94
+ | [`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 |
84
95
  | `archive`, `mapper`, `live`, `aml` | Specialty integrations |
85
96
 
86
97
  **Integration probes** — wrap a substantial external project as a plugin to confirm the lifecycle is open enough to host it without core changes. Treat these as feasibility evidence, not as a statement about where mikser is heading:
@@ -91,14 +102,24 @@ The engine is what stays stable — the lifecycle, the catalog, the file-based c
91
102
 
92
103
  ## Client SDKs
93
104
 
94
- The `api` and `vector` plugins are paired with small client-side SDKs so a frontend (or another Node app) can talk to a running mikser server without rolling its own `fetch` glue. Zero dependencies, runs in browsers / Node 18+ / Deno / Bun / Workers.
105
+ The `api`, `vector`, and `schemas` plugins are paired with client-side SDKs so a frontend (or another Node app) can talk to a running mikser server without rolling its own `fetch` glue or type contracts. Zero dependencies, runs in browsers / Node 18+ / Deno / Bun / Workers.
106
+
107
+ **Transport-level:**
95
108
 
96
109
  | Package | For the plugin | What you get |
97
110
  |---|---|---|
98
- | [`mikser-io-sdk-api`](https://github.com/almero-digital-marketing/mikser-io-sdk-api) | `api` | `entities(name).list / query / urlFor / pages / update / delete / render` — Mongo-style filter operators backed by sift, sort, projection, pagination |
111
+ | [`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 |
99
112
  | [`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 |
100
113
 
101
- Each SDK ships TypeScript declarations so client projects get autocomplete on filters, envelopes, and the `MikserError` thrown on non-2xx responses. Install only the one(s) a project needs.
114
+ **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:
115
+
116
+ | Package | Framework | Notes |
117
+ |---|---|---|
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. |
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()`. |
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. |
121
+
122
+ 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.
102
123
 
103
124
  ## Quick Start
104
125
 
@@ -130,19 +151,44 @@ npx mikser --server # build + serve at :3001
130
151
  - **Runtime Singleton** — A plain module-level object holds all global state and coordinates the lifecycle. The ES module cache guarantees every importer gets the same instance.
131
152
  - **Watch Mode** — In watch mode, file changes trigger incremental re-processing without restarting.
132
153
 
154
+ ## What you can build with it
155
+
156
+ The shape mikser fits cleanly:
157
+
158
+ - **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.
159
+ - **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern in `sdk-vue` decouples logical references from per-locale URLs. One source tree, many language deployments.
160
+ - **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.
161
+ - **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.
162
+ - **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.
163
+ - **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.
164
+
165
+ The shape mikser **doesn't** fit cleanly: anything with non-technical content authors who can't or won't work with files, anything with non-content business logic at the core, anything needing multi-tenant / per-user auth. Those aren't bugs — they're outside the design envelope. See [`decisions/0001-content-layer-not-the-app.md`](./documentation/decisions/0001-content-layer-not-the-app.md) for the explicit scope decision.
166
+
167
+ ## Engineering discipline
168
+
169
+ A few things this project takes seriously:
170
+
171
+ - **ADRs for load-bearing decisions.** The [`decisions/`](./documentation/decisions/) folder names which choices are structural — files-as-source, journal+catalog split, plugin-as-factory, compose-via-protocols — and explains what protects them. Read those before proposing a feature that pushes against one.
172
+ - **Engine stability, plugin churn.** The 15+ plugins in the ecosystem add capability without core changes. The integration probes (e.g. `decap`, mounting a third-party CMS in ~150 lines) are deliberate evidence that the extension model holds.
173
+ - **Deterministic builds.** The journal is the synchronization primitive. There's no event-passing layer, no IoC container, no plugin orchestrator. The engine knows how to fire phases in order; everything else falls out of that.
174
+ - **The mental model is one document.** The [Architecture Overview](./documentation/overview.md) is one read for the full top-to-bottom picture. The reference docs exist for lookup; the overview exists for comprehension.
175
+
133
176
  ## Documentation Index
134
177
 
135
178
  | Document | Audience | Description |
136
179
  | ----------------------------------------------------- | ------------------ | -------------------------------------------------- |
180
+ | [Architecture Overview](./documentation/overview.md) | Everyone | **Start here.** End-to-end walkthrough of how a file becomes a deployed page across all lifecycle phases. |
137
181
  | [Getting Started](./documentation/getting-started.md) | Users | Installation, first project, basic usage |
138
182
  | [Configuration](./documentation/configuration.md) | Users | All CLI options and config file reference |
139
183
  | [Lifecycle](./documentation/lifecycle.md) | Users & Developers | Complete lifecycle phases and hook system |
140
- | [Plugins](./documentation/plugins.md) | Users & Developers | Built-in plugins, writing custom plugins |
184
+ | [Plugins](./documentation/plugins.md) | Users & Developers | Built-in plugins, writing custom plugins, the assets / resources / AI pipeline |
141
185
  | [Entities](./documentation/entities.md) | Users & Developers | Entity model, operations, journal, catalog |
142
186
  | [Rendering](./documentation/rendering.md) | Users & Developers | Render pipeline, render plugins, render modes |
143
187
  | [Watch Mode](./documentation/watch-mode.md) | Users | File watching, scheduled tasks, incremental builds |
144
- | [Architecture](./documentation/architecture.md) | Developers | System design, module structure, extension points |
188
+ | [Caching](./documentation/caching.md) | Users (production) | The `cache: true` disk cache + working nginx config for reverse-proxy failover |
189
+ | [Architecture](./documentation/architecture.md) | Developers | Module-level reference — what's in each file |
145
190
  | [API Reference](./documentation/api-reference.md) | Developers | Complete public API reference |
191
+ | [Decisions (ADRs)](./documentation/decisions/) | Developers | Load-bearing architectural choices and what protects them |
146
192
 
147
193
  ## License
148
194
 
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.0",
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()
@@ -289,6 +446,7 @@ export default ({
289
446
  const { render } = useRenderer(runtime, { defaultTimeout: renderTimeout })
290
447
 
291
448
  router.get('/entities', allow('list'), auth, async (req, res) => {
449
+ const t0 = Date.now()
292
450
  try {
293
451
  const parsed = parseQueryString(req.query)
294
452
  const limit = Math.min(100, Math.max(1, parsed.limit ?? pageSize))
@@ -297,7 +455,7 @@ export default ({
297
455
  const { items, total } = await runQuery({
298
456
  filter: parsed.filter,
299
457
  sort: parsed.sort,
300
- fields: parsed.fields,
458
+ fields: resolveFields(parsed.fields),
301
459
  skip,
302
460
  limit,
303
461
  scope: query,
@@ -306,13 +464,37 @@ export default ({
306
464
 
307
465
  const page = Math.floor(skip / limit) + 1
308
466
  const totalPages = Math.ceil(total / limit) || 1
309
- res.json({
467
+ const envelope = {
310
468
  items, page, limit, total, totalPages,
311
469
  hasNext: skip + limit < total,
312
470
  hasPrev: skip > 0,
313
- })
471
+ }
472
+ res.json(envelope)
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
+ }
314
496
  } catch (err) {
315
- logger.error('Api[%s] list error: %s', name, err.message)
497
+ logger.error('Api[%s] list error (%dms): %s', name, Date.now() - t0, err.message)
316
498
  res.status(500).json({ error: err.message })
317
499
  }
318
500
  })
@@ -321,26 +503,40 @@ export default ({
321
503
  // doesn't fit cleanly in a URL: $and/$or, nested operators,
322
504
  // regex, projections, etc. Same shape as a Mongo find.
323
505
  router.post('/entities/query', allow('list'), auth, async (req, res) => {
506
+ const t0 = Date.now()
324
507
  try {
325
508
  const { filter = {}, sort, fields, page: rawPage = 1, limit: rawLimit, skip: rawSkip } = req.body ?? {}
326
509
  const limit = Math.min(100, Math.max(1, rawLimit ?? pageSize))
327
510
  const skip = rawSkip ?? (Math.max(1, parseInt(rawPage) || 1) - 1) * limit
328
511
 
329
512
  const { items, total } = await runQuery({
330
- filter, sort, fields, skip, limit,
513
+ filter, sort,
514
+ fields: resolveFields(fields),
515
+ skip, limit,
331
516
  scope: query,
332
517
  findEntities,
333
518
  })
334
519
 
335
520
  const page = Math.floor(skip / limit) + 1
336
521
  const totalPages = Math.ceil(total / limit) || 1
337
- res.json({
522
+ const envelope = {
338
523
  items, page, limit, total, totalPages,
339
524
  hasNext: skip + limit < total,
340
525
  hasPrev: skip > 0,
341
- })
526
+ }
527
+ res.json(envelope)
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.
342
538
  } catch (err) {
343
- logger.error('Api[%s] query error: %s', name, err.message)
539
+ logger.error('Api[%s] query error (%dms): %s', name, Date.now() - t0, err.message)
344
540
  res.status(500).json({ error: err.message })
345
541
  }
346
542
  })
@@ -373,6 +569,7 @@ export default ({
373
569
  endpointName: name,
374
570
  scope: query,
375
571
  filter: filterFn,
572
+ allowedFields,
376
573
  res,
377
574
  })
378
575
 
@@ -451,27 +648,63 @@ export default ({
451
648
  // before plugin onFinalized hooks. By Finalize we still have the
452
649
  // cycle's journal entries; by Finalized they're already gone.
453
650
  onFinalize(async (signal) => {
454
- if (!subscriptions.size) return
455
651
  const logger = useLogger()
456
652
  const evMap = {
457
653
  [OPERATION.CREATE]: 'create',
458
654
  [OPERATION.UPDATE]: 'update',
459
655
  [OPERATION.DELETE]: 'delete',
460
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.
461
668
  for await (const { operation, entity } of useJournal(
462
669
  'Api subscriptions',
463
670
  [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE],
464
671
  signal,
465
672
  )) {
673
+ anyChange = true
674
+
675
+ // SSE push to live subscribers
466
676
  for (const [subId, sub] of subscriptions) {
467
677
  if (sub.scope && !sub.scope(entity)) continue
468
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
469
685
  const payload = operation === OPERATION.DELETE
470
686
  ? { id: entity.id }
471
- : { id: entity.id, entity }
687
+ : { id: entity.id, entity: projected }
472
688
  sseSend(sub.res, evMap[operation], payload)
473
689
  logger.trace('Api subscription %s %s: %s', subId, evMap[operation], entity.id)
474
690
  }
475
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
+ }
476
709
  })
477
710
  }