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.
@@ -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. Note that `--explain` compares the CATALOG's entity against the
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.24.0",
3
+ "version": "9.25.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": {
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.stale ? ' [STALE: input hash moved since]' : ' [current]'))
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
- out.push(` ${e.kind.padEnd(10)} ${e.target}${bound}${e.hash ? ` ${e.hash}` : ''}`)
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)))