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.
- package/docs/diagnostics.md +44 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/runtime/mikser.sqlite +0 -0
- package/runtime/mikser.sqlite-shm +0 -0
- package/runtime/mikser.sqlite-wal +0 -0
- package/src/catalog.js +7 -0
- package/src/manager.js +24 -8
- package/src/manifest.js +21 -0
- package/src/plugins/api.js +78 -0
- package/src/report.js +52 -5
package/docs/diagnostics.md
CHANGED
|
@@ -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
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/plugins/api.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
}
|