mikser-io 9.23.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 +43 -3
- package/package.json +1 -1
- package/src/engine.js +61 -5
- package/src/explain.js +43 -3
- package/src/manifest.js +130 -0
- package/src/report.js +39 -1
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.
|
|
@@ -102,15 +118,39 @@ under this flag, so stdout parses whole.
|
|
|
102
118
|
npx mikser --json | jq '.summary'
|
|
103
119
|
```
|
|
104
120
|
|
|
105
|
-
|
|
121
|
+
Five buckets, and the distinction between them is the point:
|
|
106
122
|
|
|
107
123
|
| Bucket | Meaning |
|
|
108
124
|
| --- | --- |
|
|
109
|
-
| `rendered` | the render ran, with a `reason` per entity |
|
|
125
|
+
| `rendered` | the render ran and the output moved, with a `reason` per entity |
|
|
110
126
|
| `skipped` | the manifest decided not to render, with a `reason` |
|
|
111
127
|
| `unchanged` | the render ran and produced bytes identical to what was already on disk |
|
|
128
|
+
| `errors` | the render ran and **threw** — with `id`, `destination`, `error`, `layout` |
|
|
112
129
|
| `gated` | a count — the source was unchanged, so no render was ever scheduled |
|
|
113
130
|
|
|
131
|
+
A failed render appears in `errors` and **not** in `rendered`: that bucket
|
|
132
|
+
means the output moved, and a throw writes nothing. The previous good bytes
|
|
133
|
+
stay on disk, which is what makes a failed render survivable — and also
|
|
134
|
+
what makes it invisible without this bucket.
|
|
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
|
+
|
|
147
|
+
**A one-shot build with render errors exits `1`.** That is the signal a CI
|
|
148
|
+
gate needs, because `mikser && mikser --verify` would otherwise pass a
|
|
149
|
+
build in which nothing rendered: `--verify` compares the output against the
|
|
150
|
+
manifest, both of which still describe the last good render. Watch mode
|
|
151
|
+
keeps running — a failed render there is a state to fix on the next cycle,
|
|
152
|
+
not a reason to tear down the watcher.
|
|
153
|
+
|
|
114
154
|
`reason` is a stable vocabulary you can assert on: `unchanged`,
|
|
115
155
|
`never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
|
|
116
156
|
`cache-disabled`, `postprocessor`, `force`, `no-manifest`.
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -10,7 +10,7 @@ import { useJournal, updateEntry } from './journal.js'
|
|
|
10
10
|
import { globby } from 'globby'
|
|
11
11
|
import { OPERATION, TASKS } from './constants.js'
|
|
12
12
|
import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
|
|
13
|
-
import { reportRendered, reportSkipped, emitReport } from './report.js'
|
|
13
|
+
import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport } from './report.js'
|
|
14
14
|
import render from './render.js'
|
|
15
15
|
import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
|
|
16
16
|
import map from 'p-map'
|
|
@@ -374,7 +374,11 @@ export async function setup(options) {
|
|
|
374
374
|
logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
|
|
375
375
|
return
|
|
376
376
|
}
|
|
377
|
-
|
|
377
|
+
// Reported on the way OUT, not here: `rendered` means the
|
|
378
|
+
// output moved, and a render that throws writes nothing. The
|
|
379
|
+
// decision is carried down to the success path so the reason
|
|
380
|
+
// and its detail still travel with it.
|
|
381
|
+
//
|
|
378
382
|
// Same detail at debug, for tailing a watch run. One line per
|
|
379
383
|
// render is too much for a build's normal output — the counts
|
|
380
384
|
// are the summary and --json is the record — but when you are
|
|
@@ -522,11 +526,39 @@ export async function setup(options) {
|
|
|
522
526
|
await updateEntry({ id, output: entry.output, deps: edges })
|
|
523
527
|
}
|
|
524
528
|
|
|
529
|
+
// A success clears whatever was recorded about this
|
|
530
|
+
// destination failing, so the retry set drains itself.
|
|
531
|
+
runtime.manifest?.clearFailure(entity)
|
|
532
|
+
reportRendered(entity, decision.reason, decision)
|
|
525
533
|
logger.debug('Rendered: [%s] %s → %s', options.renderer, entity.name || entity.id, entity.destination)
|
|
526
534
|
} catch (err) {
|
|
527
535
|
if (!signal.aborted) {
|
|
528
536
|
await updateEntry({ id, output: { success: false } })
|
|
529
|
-
|
|
537
|
+
const context = formatErrorContext(entity, err, runtime.options)
|
|
538
|
+
logger.error('Render error: %s%s %s', entity.id, context, err.message)
|
|
539
|
+
// The machine-readable half of that same line. Without
|
|
540
|
+
// it a build that fails every page reports rendered:N,
|
|
541
|
+
// warnings:0 and exits 0 — three clean signals and only
|
|
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)
|
|
551
|
+
reportError(entity, err, {
|
|
552
|
+
renderer: options.renderer ?? null,
|
|
553
|
+
layout: entity.layout?.id ?? null,
|
|
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,
|
|
561
|
+
})
|
|
530
562
|
}
|
|
531
563
|
logger.debug('Render canceled')
|
|
532
564
|
}
|
|
@@ -537,8 +569,12 @@ export async function setup(options) {
|
|
|
537
569
|
concurrency: runtime.options.threads,
|
|
538
570
|
signal
|
|
539
571
|
})
|
|
540
|
-
|
|
572
|
+
// Jobs minus skips minus THROWS. Counting a failed render as
|
|
573
|
+
// rendered is the same overstatement the report used to make.
|
|
574
|
+
const failed = renderErrorCount()
|
|
575
|
+
renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped - failed)
|
|
541
576
|
skipped && logger.info('Manifest skipped: %d', skipped)
|
|
577
|
+
failed && logger.error('Render errors: %d', failed)
|
|
542
578
|
})
|
|
543
579
|
|
|
544
580
|
onBeforePostprocess(async (signal) => {
|
|
@@ -765,11 +801,31 @@ export async function setup(options) {
|
|
|
765
801
|
}
|
|
766
802
|
}
|
|
767
803
|
}
|
|
768
|
-
|
|
804
|
+
// A cycle with failed renders is not a completed build, and the word
|
|
805
|
+
// people read is this one.
|
|
806
|
+
const failed = renderErrorCount()
|
|
807
|
+
if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
|
|
808
|
+
else logger.notice('Mikser completed')
|
|
809
|
+
|
|
769
810
|
// After the cycle, and only under --json. stdout has been kept clear
|
|
770
811
|
// for exactly this (the logger writes to stderr under --json), so the
|
|
771
812
|
// document is the only thing on it and can be piped to jq.
|
|
772
813
|
emitReport()
|
|
814
|
+
|
|
815
|
+
// Non-zero for a one-shot build, so `mikser && mikser --verify` cannot
|
|
816
|
+
// pass with every page in the site stale. `exitCode` rather than
|
|
817
|
+
// process.exit so the report above is flushed and shutdown runs.
|
|
818
|
+
//
|
|
819
|
+
// Watch mode keeps going: a failed render there is a state to fix in
|
|
820
|
+
// the next cycle, not a reason to tear down the watcher. That is also
|
|
821
|
+
// what makes the failure self-concealing in watch — the errors scroll
|
|
822
|
+
// past between two green builds — so the exit code is precisely the
|
|
823
|
+
// signal CI needs and the one interactive use must not have.
|
|
824
|
+
//
|
|
825
|
+
// 1, not 2: --verify already uses 2 for output drift and --explain 3
|
|
826
|
+
// for not-found. "The build ran and some renders threw" is its own
|
|
827
|
+
// thing.
|
|
828
|
+
if (failed && !runtime.options.watch) process.exitCode = 1
|
|
773
829
|
})
|
|
774
830
|
|
|
775
831
|
onCancelled(async () => {
|
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)))
|
package/src/report.js
CHANGED
|
@@ -13,7 +13,7 @@ import runtime from './runtime.js'
|
|
|
13
13
|
|
|
14
14
|
function store() {
|
|
15
15
|
runtime.state ??= {}
|
|
16
|
-
runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], warnings: [], gated: 0 }
|
|
16
|
+
runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
|
|
17
17
|
return runtime.state.report
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -77,15 +77,53 @@ export function reportWarning(code, fields = {}) {
|
|
|
77
77
|
store().warnings.push({ code, ...fields })
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// A render that RAN and THREW. Recorded unconditionally — not gated on
|
|
81
|
+
// --json like the other buckets — because the exit code depends on the
|
|
82
|
+
// count, and a build that fails 12 renders must not exit 0 just because
|
|
83
|
+
// nobody asked for a report.
|
|
84
|
+
//
|
|
85
|
+
// Failed entities do NOT appear in `rendered`. That bucket means the output
|
|
86
|
+
// moved, and a throw writes nothing: the previous good bytes stay on disk,
|
|
87
|
+
// which is what makes the failure survivable and also what makes it
|
|
88
|
+
// invisible. `rendered: 12` beside zero written files is the misleading
|
|
89
|
+
// half, and summing buckets should not require knowing that.
|
|
90
|
+
export function reportError(entity, err, context = {}) {
|
|
91
|
+
const store = errorStore()
|
|
92
|
+
store.push({
|
|
93
|
+
id: entity?.id ?? null,
|
|
94
|
+
destination: entity?.destination ?? null,
|
|
95
|
+
error: err?.message ?? String(err),
|
|
96
|
+
...context,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Errors are counted even without --json, so they need a store that does
|
|
101
|
+
// not depend on the report being requested.
|
|
102
|
+
function errorStore() {
|
|
103
|
+
runtime.state ??= {}
|
|
104
|
+
runtime.state.renderErrors ??= []
|
|
105
|
+
return runtime.state.renderErrors
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// How many renders threw this cycle. Read by the engine to decide the
|
|
109
|
+
// process exit code.
|
|
110
|
+
export function renderErrorCount() {
|
|
111
|
+
return errorStore().length
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
export function buildReport() {
|
|
81
115
|
const report = store()
|
|
82
116
|
return {
|
|
83
117
|
rendered: report.rendered,
|
|
84
118
|
skipped: report.skipped,
|
|
85
119
|
unchanged: report.unchanged,
|
|
120
|
+
// Renders that ran and threw. A build with a non-empty `errors` is a
|
|
121
|
+
// failed build, whatever the other counts say.
|
|
122
|
+
errors: errorStore(),
|
|
86
123
|
warnings: report.warnings,
|
|
87
124
|
summary: {
|
|
88
125
|
rendered: report.rendered.length,
|
|
126
|
+
errors: errorStore().length,
|
|
89
127
|
// Of those renders, how many produced bytes identical to
|
|
90
128
|
// what was already on disk — see reportUnchanged.
|
|
91
129
|
unchanged: report.unchanged.length,
|