mikser-io 9.24.0 → 9.25.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 +28 -1
- package/package.json +1 -1
- package/src/engine.js +17 -0
- package/src/explain.js +43 -3
- package/src/manifest.js +130 -0
package/docs/diagnostics.md
CHANGED
|
@@ -54,7 +54,23 @@ rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved si
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
A snapshot written before per-input recording says so rather than
|
|
57
|
-
guessing.
|
|
57
|
+
guessing.
|
|
58
|
+
|
|
59
|
+
A destination whose **last render attempt threw** reads as such, rather
|
|
60
|
+
than as current:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
rendered 2026-08-22 21:52:24 → /page-a.html [STALE: last render attempt failed]
|
|
64
|
+
failed 2026-08-22 21:52:25 The partial partials/btn could not be found
|
|
65
|
+
3 attempts since 2026-08-22 21:52:24
|
|
66
|
+
partial /layouts/partials/btn.hbs b6ab7ccc [TARGET DELETED SINCE]
|
|
67
|
+
would re-render — the last render attempt failed and nothing has changed since
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`[TARGET DELETED SINCE]` is the same distinction one level down: an edge's
|
|
71
|
+
binding is what it resolved to *when recorded*, so a target deleted
|
|
72
|
+
afterwards still shows an id and a hash and reads as healthy unless the
|
|
73
|
+
catalog is asked. Note that `--explain` compares the CATALOG's entity against the
|
|
58
74
|
snapshot: if you have edited a file and not yet built, the verdict is
|
|
59
75
|
`source differs from the catalog` — the edit has not been imported yet, so
|
|
60
76
|
there is nothing to attribute. Build, then ask.
|
|
@@ -117,6 +133,17 @@ means the output moved, and a throw writes nothing. The previous good bytes
|
|
|
117
133
|
stay on disk, which is what makes a failed render survivable — and also
|
|
118
134
|
what makes it invisible without this bucket.
|
|
119
135
|
|
|
136
|
+
A failed render is **retried on every subsequent build** until it
|
|
137
|
+
succeeds, reported as `reason: "retry-failed"`. Nothing else would schedule
|
|
138
|
+
it — the entity's own source has not changed, so it is gated at import, and
|
|
139
|
+
the manifest still describes the last good render — so without the retry a
|
|
140
|
+
build after a failing one reported success with the site still stale.
|
|
141
|
+
|
|
142
|
+
Retries are unbounded and deliberately noisy: a page that fails every cycle
|
|
143
|
+
is failing every cycle. `errors[].since` and `errors[].attempts` are what
|
|
144
|
+
make that readable — "broke just now" and "broken for an hour" are
|
|
145
|
+
different situations. The marker clears itself on the first success.
|
|
146
|
+
|
|
120
147
|
**A one-shot build with render errors exits `1`.** That is the signal a CI
|
|
121
148
|
gate needs, because `mikser && mikser --verify` would otherwise pass a
|
|
122
149
|
build in which nothing rendered: `--verify` compares the output against the
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -526,6 +526,9 @@ export async function setup(options) {
|
|
|
526
526
|
await updateEntry({ id, output: entry.output, deps: edges })
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// A success clears whatever was recorded about this
|
|
530
|
+
// destination failing, so the retry set drains itself.
|
|
531
|
+
runtime.manifest?.clearFailure(entity)
|
|
529
532
|
reportRendered(entity, decision.reason, decision)
|
|
530
533
|
logger.debug('Rendered: [%s] %s → %s', options.renderer, entity.name || entity.id, entity.destination)
|
|
531
534
|
} catch (err) {
|
|
@@ -537,10 +540,24 @@ export async function setup(options) {
|
|
|
537
540
|
// it a build that fails every page reports rendered:N,
|
|
538
541
|
// warnings:0 and exits 0 — three clean signals and only
|
|
539
542
|
// the human log knowing otherwise.
|
|
543
|
+
// Durable, so the next cycle knows to try again and
|
|
544
|
+
// --explain stops calling this destination current.
|
|
545
|
+
runtime.manifest?.recordFailure(entity, {
|
|
546
|
+
error: err.message,
|
|
547
|
+
context: context.trim() || null,
|
|
548
|
+
at: Date.now(),
|
|
549
|
+
})
|
|
550
|
+
const failure = runtime.manifest?.failureAt(entity.id, entity.destination)
|
|
540
551
|
reportError(entity, err, {
|
|
541
552
|
renderer: options.renderer ?? null,
|
|
542
553
|
layout: entity.layout?.id ?? null,
|
|
543
554
|
context: context.trim() || null,
|
|
555
|
+
// When it STARTED failing, and how many attempts.
|
|
556
|
+
// "broke just now" and "broken since 14:02" are
|
|
557
|
+
// different situations and the reader needs to
|
|
558
|
+
// tell them apart at a glance.
|
|
559
|
+
since: failure?.firstFailedAt ?? null,
|
|
560
|
+
attempts: failure?.attempts ?? 1,
|
|
544
561
|
})
|
|
545
562
|
}
|
|
546
563
|
logger.debug('Render canceled')
|
package/src/explain.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Follows --verify's shape: report and exit, no build phases run.
|
|
11
11
|
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
12
12
|
import { filterKey } from './track.js'
|
|
13
|
-
import { findEntity, findEntities } from './catalog.js'
|
|
13
|
+
import { findEntity, findEntities, findById } from './catalog.js'
|
|
14
14
|
import runtime from './runtime.js'
|
|
15
15
|
|
|
16
16
|
const shortHash = (h) => (h ? String(h).slice(0, 8) : null)
|
|
@@ -118,6 +118,11 @@ export async function explain(reference) {
|
|
|
118
118
|
const currentHash = inputHashOf(entity)
|
|
119
119
|
const currentParts = inputPartsOf(entity)
|
|
120
120
|
const queryMatches = await countQueryMatches(snapshots)
|
|
121
|
+
// Checked before the hash comparison in the verdict: a destination whose
|
|
122
|
+
// last attempt threw will re-render regardless of what the hashes say.
|
|
123
|
+
const failedSnapshots = snapshots
|
|
124
|
+
.map(snap => runtime.manifest?.failureAt?.(entity.id, snap.destination))
|
|
125
|
+
.filter(Boolean)
|
|
121
126
|
|
|
122
127
|
// The catalog is as of the LAST BUILD. If the file has been edited since,
|
|
123
128
|
// nothing here knows it yet — the hashes would all agree and the verdict
|
|
@@ -186,6 +191,21 @@ export async function explain(reference) {
|
|
|
186
191
|
// The single most useful field: does this entity's current hash
|
|
187
192
|
// match what it was last rendered at?
|
|
188
193
|
stale: snap.inputHash !== currentHash,
|
|
194
|
+
// The last render ATTEMPT for this destination, if it threw.
|
|
195
|
+
// Without this, a destination whose render is failing reports
|
|
196
|
+
// `[current]` and `would be SKIPPED` — both true of the recorded
|
|
197
|
+
// state, and together the wrong answer to the only question
|
|
198
|
+
// --explain is ever asked.
|
|
199
|
+
failed: (() => {
|
|
200
|
+
const f = runtime.manifest?.failureAt?.(entity.id, snap.destination)
|
|
201
|
+
if (!f) return null
|
|
202
|
+
return {
|
|
203
|
+
error: f.error,
|
|
204
|
+
since: when(f.firstFailedAt),
|
|
205
|
+
lastAttempt: when(f.lastFailedAt),
|
|
206
|
+
attempts: f.attempts ?? 1,
|
|
207
|
+
}
|
|
208
|
+
})(),
|
|
189
209
|
// WHICH input moved, not merely that one did. This is the whole
|
|
190
210
|
// question behind "why did this re-render" — answering it from
|
|
191
211
|
// the recorded parts costs nothing, and not answering it sends
|
|
@@ -220,6 +240,14 @@ export async function explain(reference) {
|
|
|
220
240
|
bound: entry.targetIds?.length ? entry.targetIds
|
|
221
241
|
: entry.targetId ? [entry.targetId]
|
|
222
242
|
: [],
|
|
243
|
+
// `bound` is what the edge resolved to WHEN RECORDED.
|
|
244
|
+
// A target deleted since then still shows an id and a
|
|
245
|
+
// hash, which reads as healthy — so check the catalog
|
|
246
|
+
// rather than describing the record as if it were
|
|
247
|
+
// current.
|
|
248
|
+
gone: (entry.targetIds?.length ? entry.targetIds
|
|
249
|
+
: entry.targetId ? [entry.targetId]
|
|
250
|
+
: []).filter(id => !findById(id)),
|
|
223
251
|
hash: shortHash(entry.hash),
|
|
224
252
|
}),
|
|
225
253
|
})),
|
|
@@ -231,6 +259,9 @@ export async function explain(reference) {
|
|
|
231
259
|
+ '(Some plugins compose a checksum from several files, so verify before concluding.)'
|
|
232
260
|
: snapshots.length === 0
|
|
233
261
|
? 'never rendered — no manifest snapshot. Either it has no layout, or its layout produced no destination.'
|
|
262
|
+
: failedSnapshots.length
|
|
263
|
+
? `would re-render — the last render attempt failed and nothing has changed since `
|
|
264
|
+
+ `(${failedSnapshots[0].error})`
|
|
234
265
|
: snapshots.some(s => s.inputHash !== currentHash)
|
|
235
266
|
? renderVerdict(snapshots, currentHash, currentParts)
|
|
236
267
|
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
|
|
@@ -285,7 +316,15 @@ export function formatExplain(report) {
|
|
|
285
316
|
}
|
|
286
317
|
for (const r of report.renders) {
|
|
287
318
|
row('rendered', `${r.renderedAt ?? 'unknown'} → ${r.destination}`
|
|
288
|
-
+ (r.
|
|
319
|
+
+ (r.failed ? ' [STALE: last render attempt failed]'
|
|
320
|
+
: r.stale ? ' [STALE: input hash moved since]'
|
|
321
|
+
: ' [current]'))
|
|
322
|
+
if (r.failed) {
|
|
323
|
+
out.push(` failed ${r.failed.lastAttempt} ${r.failed.error}`)
|
|
324
|
+
if (r.failed.attempts > 1) {
|
|
325
|
+
out.push(` ${r.failed.attempts} attempts since ${r.failed.since}`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
289
328
|
const closure = r.refClosure
|
|
290
329
|
row('refClosure', `${closure.length} edge${closure.length === 1 ? '' : 's'}`)
|
|
291
330
|
for (const e of closure) {
|
|
@@ -308,7 +347,8 @@ export function formatExplain(report) {
|
|
|
308
347
|
const bound = e.bound?.length
|
|
309
348
|
? (e.bound.length === 1 && e.bound[0] === e.target ? '' : ` → ${e.bound.join(', ')}`)
|
|
310
349
|
: ' [UNRESOLVED — nothing answers to this name]'
|
|
311
|
-
|
|
350
|
+
const gone = e.gone?.length ? ' [TARGET DELETED SINCE]' : ''
|
|
351
|
+
out.push(` ${e.kind.padEnd(10)} ${e.target}${bound}${e.hash ? ` ${e.hash}` : ''}${gone}`)
|
|
312
352
|
}
|
|
313
353
|
}
|
|
314
354
|
|
package/src/manifest.js
CHANGED
|
@@ -85,6 +85,38 @@ export const SNAPSHOTS_SCHEMA = `
|
|
|
85
85
|
`
|
|
86
86
|
registerSchema('mikser_snapshots', SNAPSHOTS_SCHEMA)
|
|
87
87
|
|
|
88
|
+
// Failed render attempts, durably.
|
|
89
|
+
//
|
|
90
|
+
// A failed render writes no snapshot — deliberately, so the last good bytes
|
|
91
|
+
// survive — which leaves nothing anywhere saying the attempt happened. The
|
|
92
|
+
// consequences all follow from that one absence: the entity is gated at
|
|
93
|
+
// import next cycle (its own source did not change), so it is never
|
|
94
|
+
// re-dispatched; the manifest still describes the last good render, so
|
|
95
|
+
// --verify is clean; and --explain reports `[current]` and `would be
|
|
96
|
+
// SKIPPED` for a page whose render is throwing — the one tool whose job is
|
|
97
|
+
// "why is this not rebuilding", answering "because there is nothing to do".
|
|
98
|
+
//
|
|
99
|
+
// Keyed by (id, destination) like snapshots, but a SEPARATE table because a
|
|
100
|
+
// render that has never once succeeded has no snapshot to hang a column on.
|
|
101
|
+
//
|
|
102
|
+
// firstFailedAt is kept distinct from lastFailedAt so a report can say
|
|
103
|
+
// "since 14:02" — the difference between "this broke just now" and "this has
|
|
104
|
+
// been broken for an hour" is most of what a reader wants.
|
|
105
|
+
export const FAILURES_SCHEMA = `
|
|
106
|
+
CREATE TABLE IF NOT EXISTS mikser_failures (
|
|
107
|
+
id TEXT NOT NULL,
|
|
108
|
+
destination TEXT NOT NULL,
|
|
109
|
+
error TEXT,
|
|
110
|
+
context TEXT,
|
|
111
|
+
firstFailedAt INTEGER,
|
|
112
|
+
lastFailedAt INTEGER,
|
|
113
|
+
attempts INTEGER NOT NULL DEFAULT 1,
|
|
114
|
+
PRIMARY KEY (id, destination)
|
|
115
|
+
) WITHOUT ROWID;
|
|
116
|
+
CREATE INDEX IF NOT EXISTS idx_mikser_failures_id ON mikser_failures(id);
|
|
117
|
+
`
|
|
118
|
+
registerSchema('mikser_failures', FAILURES_SCHEMA)
|
|
119
|
+
|
|
88
120
|
// Module-level DB handle + prepared statements for the lifecycle
|
|
89
121
|
// integration. Tests build their own via `createManifest(db)` —
|
|
90
122
|
// the lifecycle hook below grabs useDatabase() and stashes the
|
|
@@ -252,6 +284,32 @@ async function hashOutputFile(destination) {
|
|
|
252
284
|
export function createManifest(db) {
|
|
253
285
|
if (!db) throw new Error('createManifest: db is required')
|
|
254
286
|
|
|
287
|
+
const stmtRecordFailure = db.prepare(`
|
|
288
|
+
INSERT INTO mikser_failures
|
|
289
|
+
(id, destination, error, context, firstFailedAt, lastFailedAt, attempts)
|
|
290
|
+
VALUES (@id, @destination, @error, @context, @at, @at, 1)
|
|
291
|
+
ON CONFLICT(id, destination) DO UPDATE SET
|
|
292
|
+
error = excluded.error,
|
|
293
|
+
context = excluded.context,
|
|
294
|
+
lastFailedAt = excluded.lastFailedAt,
|
|
295
|
+
attempts = mikser_failures.attempts + 1
|
|
296
|
+
`)
|
|
297
|
+
const stmtClearFailure = db.prepare(`
|
|
298
|
+
DELETE FROM mikser_failures WHERE id = ? AND destination = ?
|
|
299
|
+
`)
|
|
300
|
+
const stmtFailuresFor = db.prepare(`
|
|
301
|
+
SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
|
|
302
|
+
FROM mikser_failures WHERE id = ?
|
|
303
|
+
`)
|
|
304
|
+
const stmtFailureAt = db.prepare(`
|
|
305
|
+
SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
|
|
306
|
+
FROM mikser_failures WHERE id = ? AND destination = ?
|
|
307
|
+
`)
|
|
308
|
+
const stmtAllFailures = db.prepare(`
|
|
309
|
+
SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
|
|
310
|
+
FROM mikser_failures
|
|
311
|
+
`)
|
|
312
|
+
|
|
255
313
|
const stmtLookupById = db.prepare(`
|
|
256
314
|
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
257
315
|
FROM mikser_snapshots WHERE id = ? ORDER BY destination
|
|
@@ -359,6 +417,8 @@ export function createManifest(db) {
|
|
|
359
417
|
// query-matched an entity matching a recorded query mutated
|
|
360
418
|
// cache-disabled meta.cache === false
|
|
361
419
|
// force --force: skip nothing, ask nothing
|
|
420
|
+
// retry-failed the last render attempt for this destination
|
|
421
|
+
// threw; nothing else would reschedule it
|
|
362
422
|
skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
|
|
363
423
|
// --force means "ignore what you think you know". THREE gates
|
|
364
424
|
// can stop a render — source.js's import checksum gate,
|
|
@@ -373,6 +433,31 @@ export function createManifest(db) {
|
|
|
373
433
|
// no-match warning tells the operator to use it.
|
|
374
434
|
if (runtime.options?.force) return { skip: false, reason: 'force' }
|
|
375
435
|
if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
|
|
436
|
+
// A render whose last attempt threw must be retried, and checked
|
|
437
|
+
// before anything else: every other branch reasons about hashes,
|
|
438
|
+
// and the hashes are consistent — the entity did not change, the
|
|
439
|
+
// snapshot still describes the last GOOD render. Consistency is
|
|
440
|
+
// exactly why the failure is invisible without this.
|
|
441
|
+
//
|
|
442
|
+
// Retried unbounded, and noisily. A page that fails every cycle IS
|
|
443
|
+
// failing every cycle, and a build that stops mentioning it after
|
|
444
|
+
// the third attempt is making the same trade as reporting
|
|
445
|
+
// `rendered: 12, exit 0`. What makes it tolerable is presentation
|
|
446
|
+
// — one line per failing entity, and `since` on the report so a
|
|
447
|
+
// reader can tell "broke just now" from "broken since 14:02" —
|
|
448
|
+
// not backoff.
|
|
449
|
+
const failure = this.failureAt(entity?.id, entity?.destination)
|
|
450
|
+
if (failure) {
|
|
451
|
+
return {
|
|
452
|
+
skip: false,
|
|
453
|
+
reason: 'retry-failed',
|
|
454
|
+
failure: {
|
|
455
|
+
error: failure.error,
|
|
456
|
+
since: failure.firstFailedAt ?? null,
|
|
457
|
+
attempts: failure.attempts ?? 1,
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
}
|
|
376
461
|
const snapshot = this.lookup(entity)
|
|
377
462
|
if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
|
|
378
463
|
if (inputHashOf(entity) !== snapshot.inputHash) {
|
|
@@ -483,6 +568,51 @@ export function createManifest(db) {
|
|
|
483
568
|
return { skip: true, reason: 'unchanged' }
|
|
484
569
|
},
|
|
485
570
|
|
|
571
|
+
// Record that a render attempt threw. `at` is passed in rather than
|
|
572
|
+
// read from the clock here so the caller owns the timestamp.
|
|
573
|
+
recordFailure(entity, { error, context, at }) {
|
|
574
|
+
if (!entity?.id || !entity?.destination) return
|
|
575
|
+
stmtRecordFailure.run({
|
|
576
|
+
id: entity.id,
|
|
577
|
+
destination: entity.destination,
|
|
578
|
+
error: error ?? null,
|
|
579
|
+
context: context ?? null,
|
|
580
|
+
at: at ?? Date.now(),
|
|
581
|
+
})
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
// A render succeeded, so whatever was recorded about it failing is
|
|
585
|
+
// no longer true. Called on every success, not only after a failure —
|
|
586
|
+
// it is a cheap DELETE and forgetting it would strand the marker.
|
|
587
|
+
clearFailure(entity) {
|
|
588
|
+
if (!entity?.id || !entity?.destination) return
|
|
589
|
+
stmtClearFailure.run(entity.id, entity.destination)
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
// Every recorded failure for an entity, across destinations.
|
|
593
|
+
failuresFor(id) {
|
|
594
|
+
return id ? stmtFailuresFor.all(id) : []
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
// One, at a known destination.
|
|
598
|
+
failureAt(id, destination) {
|
|
599
|
+
if (!id || !destination) return null
|
|
600
|
+
return stmtFailureAt.get(id, destination) ?? null
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
// Every entity id with a recorded failure. The dispatch set a
|
|
604
|
+
// task-production plugin unions in so a failed render is retried:
|
|
605
|
+
// the entity's own source has not changed, so nothing else will
|
|
606
|
+
// schedule it, and going quiet about a page that will not build is
|
|
607
|
+
// the failure mode this whole area exists to avoid.
|
|
608
|
+
failedIds() {
|
|
609
|
+
return [...new Set(stmtAllFailures.all().map(row => row.id))]
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
allFailures() {
|
|
613
|
+
return stmtAllFailures.all()
|
|
614
|
+
},
|
|
615
|
+
|
|
486
616
|
// Record a successful render. Single INSERT OR REPLACE.
|
|
487
617
|
record(entity, deps) {
|
|
488
618
|
stmtUpsert.run(snapToRow(buildSnapshot(entity, deps)))
|