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 +7 -2
- package/app.js +1 -0
- package/package.json +1 -1
- package/src/plugins/api.js +239 -10
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 (`
|
|
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
package/package.json
CHANGED
package/src/plugins/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|