mikser-io 9.25.0 → 9.26.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.
@@ -261,6 +261,50 @@ without touching files whose bytes did not move, so it is cheap to reach
261
261
  for and its `unchanged` count tells you how much of the catalog was stale
262
262
  by suspicion rather than in fact.
263
263
 
264
+ ## Over a transport
265
+
266
+ Everything above assumes a shell on the machine. Three of these questions
267
+ are also answerable from a running server, which is what CI, a dashboard,
268
+ an SDK, or an agent speaking MCP actually has.
269
+
270
+ **MCP** — `mikser_explain`, `mikser_build_report`, `mikser_verify`,
271
+ alongside the existing `mikser_refs_*`, `mikser_layouts_inspect` and the
272
+ `mikser://logs/recent` resource.
273
+
274
+ **REST** — on the `api` plugin, gated on their own `diagnostics`
275
+ operation:
276
+
277
+ ```
278
+ GET <endpoint>/explain?reference=/documents/en/page.md
279
+ GET <endpoint>/report
280
+ GET <endpoint>/verify
281
+ ```
282
+
283
+ ```js
284
+ api({ endpoints: {
285
+ ops: { token: process.env.OPS_TOKEN, operations: ['diagnostics'] },
286
+ } })
287
+ ```
288
+
289
+ `diagnostics` is in **no** default operation set, and that is deliberate
290
+ rather than tidy: these responses carry absolute filesystem paths, layout
291
+ ids and raw error text. Folding them into `list` would leak engine
292
+ internals through every endpoint that only meant to publish content, and
293
+ adding them to the token default would do it silently on upgrade.
294
+
295
+ `/verify` answers `200` whether the verdict is `OK`, `WARN` or `FAIL` —
296
+ the check ran, and that is its answer. A status code would conflate "drift
297
+ found" with "request failed", so a CI gate reads `verdict`, which mirrors
298
+ the CLI's exit vocabulary.
299
+
300
+ The build report is recorded only when something can read it: `--json`, or
301
+ a transport that asked for it. It describes the LAST cycle — a watch
302
+ server clears it as each new cycle starts, so "what did that rebuild do"
303
+ does not become "everything since boot".
304
+
305
+ There is no transport for `--force`. A forced rebuild of a large site is a
306
+ denial-of-service knob rather than a diagnostic, and it stays on the CLI.
307
+
264
308
  ## The database
265
309
 
266
310
  Everything mikser knows lives in one SQLite file at
package/index.js CHANGED
@@ -3,6 +3,10 @@ export * as constants from './src/constants.js'
3
3
  export * from './src/utils.js'
4
4
  export * from './src/auth.js'
5
5
  export * from './src/report.js'
6
+ // The diagnostics behind --explain. Exported so a transport — the MCP tool
7
+ // surface, the api plugin's routes — can serve the same structured report the
8
+ // CLI formats, rather than each one reimplementing the question.
9
+ export * from './src/explain.js'
6
10
  export * from './src/lifecycle.js'
7
11
  export * from './src/database/index.js'
8
12
  export * from './src/journal.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.25.0",
3
+ "version": "9.26.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
  "exports": {
Binary file
Binary file
Binary file
package/src/catalog.js CHANGED
@@ -226,6 +226,13 @@ async function applyJournalMutations() {
226
226
  logger.trace('Database %s %s: %s', entity.collection, operation, entity.id)
227
227
  stmtDelete.run(entity.id)
228
228
  // FK ON DELETE CASCADE handles mikser_refs cleanup.
229
+ // mikser_failures is cleared explicitly rather than by
230
+ // cascade — see manifest.clearFailures for why it cannot
231
+ // be a foreign key. Left behind, one row for a deleted
232
+ // entity keeps the dispatch set non-empty for the life of
233
+ // the database, so layouts' idle-cycle early-out never
234
+ // fires again.
235
+ runtime.manifest?.clearFailures(entity.id)
229
236
  cacheEvict(entity.id)
230
237
  break
231
238
  }
package/src/manager.js CHANGED
@@ -2,12 +2,32 @@ import runtime from './runtime.js'
2
2
  import chokidar from 'chokidar'
3
3
  import cron from 'node-cron'
4
4
  import { onProcess, onFinalized } from './lifecycle.js'
5
+ import { resetReport } from './report.js'
5
6
  import { useLogger } from './engine.js'
6
7
  import { ACTION } from './constants.js'
7
8
  import { junkFilter } from './utils.js'
8
9
 
9
10
  const tasks = []
10
11
 
12
+ // The watch-cycle trigger, in one place rather than four copies.
13
+ //
14
+ // Debounced by a second so a burst of file events becomes one cycle. The
15
+ // report is cleared as the cycle STARTS, not when it is scheduled, so it
16
+ // always describes the cycle that just ran: without this it accumulates for
17
+ // the life of a watch process, and "what did the last rebuild do" becomes
18
+ // "here is everything since boot, find the end yourself".
19
+ //
20
+ // Not done inside runtime.process(): the first cycle's `gated` count is
21
+ // recorded during import, which runs BEFORE process(), so resetting there
22
+ // would wipe it out of a one-shot build's report.
23
+ function scheduleProcess() {
24
+ clearTimeout(runtime.engine.processTimeout)
25
+ runtime.engine.processTimeout = setTimeout(() => {
26
+ resetReport()
27
+ runtime.process()
28
+ }, 1000)
29
+ }
30
+
11
31
  export async function createdHook(name, context) {
12
32
  if (!runtime.started) return
13
33
 
@@ -18,8 +38,7 @@ export async function createdHook(name, context) {
18
38
  })
19
39
 
20
40
  if (synced) {
21
- clearTimeout(runtime.engine.processTimeout)
22
- runtime.engine.processTimeout = setTimeout(() => runtime.process(), 1000)
41
+ scheduleProcess()
23
42
  }
24
43
  }
25
44
 
@@ -33,8 +52,7 @@ export async function updatedHook(name, context) {
33
52
  })
34
53
 
35
54
  if (synced) {
36
- clearTimeout(runtime.engine.processTimeout)
37
- runtime.engine.processTimeout = setTimeout(() => runtime.process(), 1000)
55
+ scheduleProcess()
38
56
  }
39
57
  }
40
58
 
@@ -48,8 +66,7 @@ export async function triggeredHook(name, context) {
48
66
  })
49
67
 
50
68
  if (synced) {
51
- clearTimeout(runtime.engine.processTimeout)
52
- runtime.engine.processTimeout = setTimeout(() => runtime.process(), 1000)
69
+ scheduleProcess()
53
70
  }
54
71
  }
55
72
 
@@ -63,8 +80,7 @@ export async function deletedHook(name, context) {
63
80
  })
64
81
 
65
82
  if (synced) {
66
- clearTimeout(runtime.engine.processTimeout)
67
- runtime.engine.processTimeout = setTimeout(() => runtime.process(), 1000)
83
+ scheduleProcess()
68
84
  }
69
85
  }
70
86
 
package/src/manifest.js CHANGED
@@ -297,6 +297,9 @@ export function createManifest(db) {
297
297
  const stmtClearFailure = db.prepare(`
298
298
  DELETE FROM mikser_failures WHERE id = ? AND destination = ?
299
299
  `)
300
+ const stmtClearFailuresForId = db.prepare(`
301
+ DELETE FROM mikser_failures WHERE id = ?
302
+ `)
300
303
  const stmtFailuresFor = db.prepare(`
301
304
  SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
302
305
  FROM mikser_failures WHERE id = ?
@@ -581,6 +584,24 @@ export function createManifest(db) {
581
584
  })
582
585
  },
583
586
 
587
+ // The entity is gone, so every failure recorded against it is
588
+ // irrelevant regardless of which destination it was recorded at.
589
+ //
590
+ // Deliberately NOT a foreign key with ON DELETE CASCADE, which is how
591
+ // mikser_refs handles the same situation: a render task's id is not
592
+ // guaranteed to be a row in mikser_entities — snapshots carry a
593
+ // `parent` precisely because paginated children render under derived
594
+ // ids — so an FK would make recordFailure throw from inside the
595
+ // handler that exists to report a render error, turning a reported
596
+ // failure into a crash.
597
+ //
598
+ // A rename presents as delete + create under a new id, so this covers
599
+ // that residue too: the old id's rows go with the delete.
600
+ clearFailures(id) {
601
+ if (!id) return
602
+ stmtClearFailuresForId.run(id)
603
+ },
604
+
584
605
  // A render succeeded, so whatever was recorded about it failing is
585
606
  // no longer true. Called on every success, not only after a failure —
586
607
  // it is a cheap DELETE and forgetting it would strand the marker.
@@ -4,6 +4,8 @@ import { createHash } from 'node:crypto'
4
4
  import _ from 'lodash'
5
5
  import sift from 'sift'
6
6
  import { resolveAuth, requireAuth, hasCapability, reachabilityOf } from '../auth.js'
7
+ import { explain } from '../explain.js'
8
+ import { buildReport, requestReport } from '../report.js'
7
9
  import { useRenderer } from '../render.js'
8
10
  import { mimeForEntity, isLoopback, ExpandError, useCollection } from '../utils.js'
9
11
  import { registerRoute } from '../routes.js'
@@ -393,6 +395,11 @@ export function api(options = {}) {
393
395
  //
394
396
  // api.endpoints.public { query: e => e.meta?.published, operations: ['list'] }
395
397
  // api.endpoints.admin { token: '...', operations: ['list','update','delete','render'] }
398
+ // api.endpoints.ops { token: '...', operations: ['diagnostics'] }
399
+ //
400
+ // `diagnostics` is in no default set: /explain, /report and /verify
401
+ // carry absolute paths and raw error text, so exposing them is a
402
+ // decision rather than a side effect of having a token.
396
403
  for (const [name, ep] of Object.entries(endpoints)) {
397
404
  const router = express.Router()
398
405
 
@@ -438,6 +445,10 @@ export function api(options = {}) {
438
445
  ? ['list', 'update', 'delete', 'render', 'subscribe']
439
446
  : ['list']
440
447
  const allowedOps = new Set(ep.operations ?? defaultOps)
448
+ // Recording the build report costs one entry per entity per
449
+ // cycle, so it is off unless something can read it. An endpoint
450
+ // exposing /report is that something.
451
+ if (allowedOps.has('diagnostics')) requestReport()
441
452
 
442
453
  // The endpoint's scope. A sift filter is the form to prefer —
443
454
  // queryEntities merges it into the WHERE clause, so the endpoint
@@ -922,6 +933,73 @@ export function api(options = {}) {
922
933
  }
923
934
  })
924
935
 
936
+ // ── Diagnostics ────────────────────────────────────────
937
+ //
938
+ // The three questions --explain, --json and --verify answer, for
939
+ // a RUNNING server: CI asking "did that deploy actually rebuild
940
+ // anything", a dashboard, an SDK, anything not speaking MCP.
941
+ // `mikser && mikser --verify` becomes a request against the
942
+ // instance that is actually serving.
943
+ //
944
+ // Gated on their own `diagnostics` operation, which is in NEITHER
945
+ // default op set. That is deliberate rather than tidy: these
946
+ // responses carry absolute filesystem paths, layout ids and raw
947
+ // error messages, so folding them into `list` would leak engine
948
+ // internals through every endpoint that only meant to expose
949
+ // published content. An operator opts in per endpoint.
950
+ // `?reference=` rather than a path segment: an entity id contains
951
+ // slashes (/documents/en/page.md), which as a path would need a
952
+ // wildcard — and Express 5's path-to-regexp rejects the v4
953
+ // `:reference(*)` form outright.
954
+ router.get('/explain', auth, allow('diagnostics'), async (req, res) => {
955
+ try {
956
+ const reference = req.query.reference
957
+ if (!reference) {
958
+ return res.status(400).json({ error: 'Missing ?reference= (entity id, meta.href, or id without its extension)' })
959
+ }
960
+ const report = await explain(String(reference))
961
+ // 404 for an entity that is not there: `found: false` is a
962
+ // real answer with a hint attached, but a REST caller
963
+ // reasonably reads status before body.
964
+ return res.status(report.found === false ? 404 : 200).json(report)
965
+ } catch (err) {
966
+ logger.error('Api explain error: %s', err.message)
967
+ return res.status(500).json({ error: err.message })
968
+ }
969
+ })
970
+
971
+ router.get('/report', auth, allow('diagnostics'), async (req, res) => {
972
+ try {
973
+ return res.json(buildReport())
974
+ } catch (err) {
975
+ logger.error('Api report error: %s', err.message)
976
+ return res.status(500).json({ error: err.message })
977
+ }
978
+ })
979
+
980
+ router.get('/verify', auth, allow('diagnostics'), async (req, res) => {
981
+ try {
982
+ if (!runtime.manifest?.verify) {
983
+ return res.status(503).json({ error: 'No manifest available — nothing to verify against' })
984
+ }
985
+ const diff = await runtime.manifest.verify()
986
+ const errors = diff.missing.length + diff.mismatched.length
987
+ const warnings = diff.orphaned.length + diff.unverifiable.length
988
+ // 200 either way — the check ran and this is its answer. A
989
+ // CI gate reads `verdict`, which mirrors the CLI's exit
990
+ // vocabulary, rather than inferring from a status code that
991
+ // would conflate "drift found" with "request failed".
992
+ return res.json({
993
+ verdict: errors > 0 ? 'FAIL' : warnings > 0 ? 'WARN' : 'OK',
994
+ snapshots: runtime.manifest.size?.() ?? null,
995
+ ...diff,
996
+ })
997
+ } catch (err) {
998
+ logger.error('Api verify error: %s', err.message)
999
+ return res.status(500).json({ error: err.message })
1000
+ }
1001
+ })
1002
+
925
1003
  router.post('/render', auth, allow('render'), async (req, res) => {
926
1004
  // Hoisted so the catch can name WHICH entity failed. A
927
1005
  // render that throws before this is assigned is itself the
package/src/report.js CHANGED
@@ -11,6 +11,50 @@
11
11
  // when someone improves the wording.
12
12
  import runtime from './runtime.js'
13
13
 
14
+ // A transport that can serve the build report declares itself here, at
15
+ // factory time, before any cycle runs.
16
+ //
17
+ // Recording was gated on --json alone, which is why the report existed only
18
+ // for a one-shot build someone piped to jq. An in-process reader is exactly
19
+ // as much of a consumer, and a watch server is where "what did the last
20
+ // cycle do, and why" is most worth asking.
21
+ //
22
+ // An opt-in call rather than report.js testing for known plugins: the list
23
+ // of things that can read a report is not report.js's to keep, and a third
24
+ // transport should not need an edit here to work.
25
+ export function requestReport() {
26
+ runtime.options ??= {}
27
+ runtime.options.reportRequested = true
28
+ }
29
+
30
+ // Is anything able to read the report?
31
+ //
32
+ // Not recorded unconditionally: the rendered/skipped/unchanged arrays are
33
+ // one entry per entity per cycle, which `gated` is already a bare count to
34
+ // avoid. Recording when there IS a reader keeps the default lean without
35
+ // making the data conditional on how you happen to have started mikser.
36
+ function reportWanted() {
37
+ return !!(runtime.options?.json || runtime.options?.reportRequested)
38
+ }
39
+
40
+ // Cleared at the start of every cycle, so the report always describes the
41
+ // LAST one rather than everything since the process started.
42
+ //
43
+ // It did not need this while --json meant a single build that then exited.
44
+ // Under watch, without it, rendered/skipped grow without bound and the
45
+ // answer to "what did the last rebuild do" becomes "here is everything,
46
+ // find the end yourself".
47
+ //
48
+ // The failure store is deliberately NOT cleared here: a failure persists
49
+ // across cycles by design — it is the retry marker's in-memory twin, and
50
+ // the exit code depends on this cycle's count, which resetRenderErrors
51
+ // handles at the same point.
52
+ export function resetReport() {
53
+ if (!runtime.state) return
54
+ runtime.state.report = { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
55
+ runtime.state.renderErrors = []
56
+ }
57
+
14
58
  function store() {
15
59
  runtime.state ??= {}
16
60
  runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
@@ -24,7 +68,7 @@ function store() {
24
68
  // almost the whole catalog on almost every build, which is noise in a
25
69
  // document meant to answer "did my change land".
26
70
  export function reportGated(count = 1) {
27
- if (!runtime.options?.json) return
71
+ if (!reportWanted()) return
28
72
  store().gated += count
29
73
  }
30
74
 
@@ -38,7 +82,7 @@ export function reportGated(count = 1) {
38
82
  // `matched`, `dependency` each mean something specific, and a single
39
83
  // polymorphic key would push the type switch onto every consumer.
40
84
  export function reportRendered(entity, reason, decision = {}) {
41
- if (!runtime.options?.json) return
85
+ if (!reportWanted()) return
42
86
  store().rendered.push({
43
87
  id: entity?.id,
44
88
  destination: entity?.destination ?? null,
@@ -57,12 +101,12 @@ export function reportRendered(entity, reason, decision = {}) {
57
101
  // to be. Nothing downstream is disturbed, and the count measures what
58
102
  // conservative invalidation costs.
59
103
  export function reportUnchanged(entity) {
60
- if (!runtime.options?.json) return
104
+ if (!reportWanted()) return
61
105
  store().unchanged.push({ id: entity?.id, destination: entity?.destination ?? null })
62
106
  }
63
107
 
64
108
  export function reportSkipped(entity, reason) {
65
- if (!runtime.options?.json) return
109
+ if (!reportWanted()) return
66
110
  store().skipped.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
67
111
  }
68
112
 
@@ -73,7 +117,7 @@ export function reportSkipped(entity, reason) {
73
117
  // `code` is the contract. Add fields freely; renaming a code is a breaking
74
118
  // change to anyone asserting on it.
75
119
  export function reportWarning(code, fields = {}) {
76
- if (!runtime.options?.json) return
120
+ if (!reportWanted()) return
77
121
  store().warnings.push({ code, ...fields })
78
122
  }
79
123
 
@@ -141,6 +185,9 @@ export function buildReport() {
141
185
  // Emitted once, after the cycle, on stdout — which the logger has vacated
142
186
  // under --json precisely so this can be the only thing there.
143
187
  export function emitReport() {
188
+ // --json only, deliberately. Recording and PRINTING are different
189
+ // questions: a server with the mcp plugin records so a tool can read it,
190
+ // and must not write a document to stdout.
144
191
  if (!runtime.options?.json) return
145
192
  process.stdout.write(JSON.stringify(buildReport(), null, 2) + '\n')
146
193
  }