mikser-io 10.0.1 → 10.0.2
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/package.json +2 -2
- package/src/explain.js +26 -0
- package/src/manifest.js +99 -0
- package/src/source.js +18 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.2",
|
|
4
4
|
"files": [
|
|
5
5
|
"app.js",
|
|
6
6
|
"index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"mikser-mark.svg",
|
|
13
13
|
"mikser-lockup-stacked.svg"
|
|
14
14
|
],
|
|
15
|
-
"description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition
|
|
15
|
+
"description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
|
|
16
16
|
"main": "index.js",
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./index.js",
|
package/src/explain.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// needs knowledge a user of the tool should not need.
|
|
9
9
|
//
|
|
10
10
|
// Follows --audit-output's shape: report and exit, no build phases run.
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
11
12
|
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
13
|
+
import { resolveOutputPath } from './manifest.js'
|
|
12
14
|
import { filterKey } from './track.js'
|
|
13
15
|
import { findEntity, findEntities, findById } from './catalog.js'
|
|
14
16
|
import runtime from './runtime.js'
|
|
@@ -134,6 +136,13 @@ export async function explain(reference) {
|
|
|
134
136
|
const failedSnapshots = snapshots
|
|
135
137
|
.map(snap => runtime.manifest?.failureAt?.(entity.id, snap.destination))
|
|
136
138
|
.filter(Boolean)
|
|
139
|
+
// Same position in the verdict for the same reason: a destination whose
|
|
140
|
+
// file is gone re-renders whatever the hashes say. Ranked below a failed
|
|
141
|
+
// attempt — if the last render threw, that is the more useful sentence,
|
|
142
|
+
// and it explains the absence too.
|
|
143
|
+
const missingDestinations = snapshots
|
|
144
|
+
.filter(snap => snap.destination && !existsSync(resolveOutputPath(snap.destination)))
|
|
145
|
+
.map(snap => snap.destination)
|
|
137
146
|
|
|
138
147
|
// The catalog is as of the LAST BUILD. If the file has been edited since,
|
|
139
148
|
// nothing here knows it yet — the hashes would all agree and the verdict
|
|
@@ -226,6 +235,18 @@ export async function explain(reference) {
|
|
|
226
235
|
: snap.inputParts
|
|
227
236
|
? diffInputParts(snap.inputParts, currentParts)
|
|
228
237
|
: 'unknown',
|
|
238
|
+
// The file this render wrote, gone from disk.
|
|
239
|
+
//
|
|
240
|
+
// Same shape as `failed` above and added for the same reason: the
|
|
241
|
+
// recorded state is entirely consistent — the input hash matches,
|
|
242
|
+
// the snapshot is intact — so without this the report reads
|
|
243
|
+
// `[current]` and `would be SKIPPED` for an entity a build now
|
|
244
|
+
// re-renders. --explain contradicting the build is worse than
|
|
245
|
+
// --explain being incomplete, because it is the tool someone
|
|
246
|
+
// reaches for when the build has already surprised them.
|
|
247
|
+
missing: snap.destination
|
|
248
|
+
? !existsSync(resolveOutputPath(snap.destination))
|
|
249
|
+
: false,
|
|
229
250
|
outputHash: snap.outputHash ?? null,
|
|
230
251
|
parent: snap.parent ?? null,
|
|
231
252
|
// Which keys of its OWN meta the render read, and which keys it
|
|
@@ -286,6 +307,10 @@ export async function explain(reference) {
|
|
|
286
307
|
+ `(${failedSnapshots[0].error})`
|
|
287
308
|
: snapshots.some(s => s.inputHash !== currentHash)
|
|
288
309
|
? renderVerdict(snapshots, currentHash, currentParts)
|
|
310
|
+
: missingDestinations.length
|
|
311
|
+
? `would re-render — the output is gone from disk (${missingDestinations.join(', ')}). `
|
|
312
|
+
+ 'The inputs are unchanged, so nothing about the entity says this; the file being '
|
|
313
|
+
+ 'absent is the whole reason.'
|
|
289
314
|
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.'),
|
|
290
315
|
lookupKeys: lookupKeys(entity),
|
|
291
316
|
// Other entities rendering to the same path as this one.
|
|
@@ -360,6 +385,7 @@ export function formatExplain(report) {
|
|
|
360
385
|
for (const r of report.renders) {
|
|
361
386
|
row('rendered', `${r.renderedAt ?? 'unknown'} → ${r.destination}`
|
|
362
387
|
+ (r.failed ? ' [STALE: last render attempt failed]'
|
|
388
|
+
: r.missing ? ' [MISSING: the file is not on disk]'
|
|
363
389
|
: r.stale ? ' [STALE: input hash moved since]'
|
|
364
390
|
: ' [current]'))
|
|
365
391
|
if (r.failed) {
|
package/src/manifest.js
CHANGED
|
@@ -372,6 +372,11 @@ export async function sourcesOf(destination) {
|
|
|
372
372
|
export function createManifest(db) {
|
|
373
373
|
if (!db) throw new Error('createManifest: db is required')
|
|
374
374
|
|
|
375
|
+
// Per-instance, not module-level: tests build their own manifest via
|
|
376
|
+
// createManifest(db), and a shared memo would leak one test's output
|
|
377
|
+
// folder into the next.
|
|
378
|
+
let cachedMissingOutputs = null
|
|
379
|
+
|
|
375
380
|
const stmtCollisions = db.prepare(`
|
|
376
381
|
SELECT destination, count(*) AS n, group_concat(id) AS ids
|
|
377
382
|
FROM mikser_snapshots
|
|
@@ -657,6 +662,7 @@ export function createManifest(db) {
|
|
|
657
662
|
//
|
|
658
663
|
// unchanged nothing this render depends on moved
|
|
659
664
|
// never-rendered no snapshot: first build, or it never rendered
|
|
665
|
+
// output-missing the file the last render wrote is gone from disk
|
|
660
666
|
// inputs-changed the entity's own hash moved
|
|
661
667
|
// ref-changed a $-ref or partial it depends on moved
|
|
662
668
|
// query-matched an entity matching a recorded query mutated
|
|
@@ -715,6 +721,32 @@ export function createManifest(db) {
|
|
|
715
721
|
changed: describeInputChange(entity, snapshot),
|
|
716
722
|
}
|
|
717
723
|
}
|
|
724
|
+
// The inputs say nothing moved. That is only a reason to skip if
|
|
725
|
+
// the thing the last render PRODUCED is still there.
|
|
726
|
+
//
|
|
727
|
+
// Every branch above this one reasons about inputs, and inputs are
|
|
728
|
+
// not where `rm -rf out` shows up: the documents are unchanged,
|
|
729
|
+
// because what was deleted is the output. So the build skips every
|
|
730
|
+
// entity, renders nothing, prints no `Rendered:` line and exits 0
|
|
731
|
+
// with an empty output folder — correct by its own reasoning and
|
|
732
|
+
// wrong about the only question the caller asked. A missing output
|
|
733
|
+
// is a reason to re-render, not a reason to report unchanged.
|
|
734
|
+
//
|
|
735
|
+
// Placed after the input comparison and before the refClosure walk
|
|
736
|
+
// so one check covers both `unchanged` exits, and placed here
|
|
737
|
+
// rather than in the render loop because this is the function that
|
|
738
|
+
// owns the word: anything asking shouldSkip() gets the same answer.
|
|
739
|
+
//
|
|
740
|
+
// Checked unconditionally rather than behind a flag. It is one stat
|
|
741
|
+
// per entity that reached this point — 2.2ms for 1842 entities,
|
|
742
|
+
// the same whether the files are there or not — against a failure
|
|
743
|
+
// mode whose whole character is that nobody thinks to look for it.
|
|
744
|
+
// auditOutput() resolves destinations exactly this way, and it has
|
|
745
|
+
// to stay that way: the two disagreeing is the bug, since `--audit-output`
|
|
746
|
+
// finding what the build just called clean is what sent someone here.
|
|
747
|
+
if (snapshot.destination && !existsSync(resolveOutputPath(snapshot.destination))) {
|
|
748
|
+
return { skip: false, reason: 'output-missing', destination: snapshot.destination }
|
|
749
|
+
}
|
|
718
750
|
if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
|
|
719
751
|
const sourceLang = entity?.meta?.lang ?? null
|
|
720
752
|
for (const entry of snapshot.refClosure) {
|
|
@@ -988,12 +1020,25 @@ export function createManifest(db) {
|
|
|
988
1020
|
// for the same id (matches the prior first-seen-wins semantic;
|
|
989
1021
|
// entity inputHashes were always added before deps in the
|
|
990
1022
|
// earlier loop).
|
|
1023
|
+
// An entity whose output is gone is omitted entirely. The consumer
|
|
1024
|
+
// uses a recorded hash to decide "this needs no render", and that
|
|
1025
|
+
// conclusion only follows while the file the hash describes is still
|
|
1026
|
+
// there. Omitting is the whole fix at this gate: a seed with no
|
|
1027
|
+
// recorded hash is always dispatched.
|
|
1028
|
+
//
|
|
1029
|
+
// Done here rather than in the dispatcher because this map exists for
|
|
1030
|
+
// that one decision, and a second consumer would otherwise have to
|
|
1031
|
+
// rediscover the same caveat. missingOutputIds() is memoized for the
|
|
1032
|
+
// cycle, so this shares the walk the source gate already paid for.
|
|
991
1033
|
recordedHashes() {
|
|
1034
|
+
const missingOutputs = this.missingOutputIds()
|
|
992
1035
|
const map = new Map()
|
|
993
1036
|
for (const row of stmtEntityInputHashes.iterate()) {
|
|
1037
|
+
if (missingOutputs.has(row.id)) continue
|
|
994
1038
|
map.set(row.id, row.inputHash)
|
|
995
1039
|
}
|
|
996
1040
|
for (const row of stmtDepHashes.iterate()) {
|
|
1041
|
+
if (missingOutputs.has(row.target)) continue
|
|
997
1042
|
if (!map.has(row.target)) map.set(row.target, row.hash)
|
|
998
1043
|
}
|
|
999
1044
|
return map
|
|
@@ -1061,6 +1106,53 @@ export function createManifest(db) {
|
|
|
1061
1106
|
return edges
|
|
1062
1107
|
},
|
|
1063
1108
|
|
|
1109
|
+
// The entity ids whose last render wrote a file that is no longer
|
|
1110
|
+
// on disk. A dispatch hint, the same shape as queryAffected: the
|
|
1111
|
+
// source checksum gate consults it so those entities are re-emitted
|
|
1112
|
+
// instead of being short-circuited as unchanged.
|
|
1113
|
+
//
|
|
1114
|
+
// This exists because the gate that stops `rm -rf out` from being
|
|
1115
|
+
// noticed is the FIRST one, not the last. An unchanged file never
|
|
1116
|
+
// gets a CREATE, so it never reaches the journal, so the render loop
|
|
1117
|
+
// never sees it and manifest.skipDecision — which does check for a
|
|
1118
|
+
// missing output — is never asked. Fixing only the render gate makes
|
|
1119
|
+
// the build correct for entities that get that far and changes
|
|
1120
|
+
// nothing for the case in the report, where none of them do.
|
|
1121
|
+
//
|
|
1122
|
+
// One stat per recorded snapshot, once per cycle, hoisted out of the
|
|
1123
|
+
// per-file path exactly like checksumsByCollection. The syscalls are
|
|
1124
|
+
// ~2.2ms per 1842 paths and cost the same whether the files are there
|
|
1125
|
+
// or not; at the build level a warm 2000-document rebuild measured
|
|
1126
|
+
// 1.05s against 1.08s without it, which is inside the run-to-run
|
|
1127
|
+
// spread — the walk disappears into the cycle it is part of.
|
|
1128
|
+
//
|
|
1129
|
+
// Resolves destinations through resolveOutputPath, which is what
|
|
1130
|
+
// auditOutput uses. The two must agree — `--audit-output` reporting
|
|
1131
|
+
// files that the build just called unchanged is the symptom, not a
|
|
1132
|
+
// separate diagnostic.
|
|
1133
|
+
// Memoized for the cycle. Two callers ask — the source checksum gate
|
|
1134
|
+
// once per scan, recordedHashes() once per layouts dispatch — and both
|
|
1135
|
+
// ask before anything has rendered, so one walk answers both. Cleared
|
|
1136
|
+
// at the end of onFinalize, which is where this cycle's renders are
|
|
1137
|
+
// recorded and the answer stops being true.
|
|
1138
|
+
missingOutputIds() {
|
|
1139
|
+
if (cachedMissingOutputs) return cachedMissingOutputs
|
|
1140
|
+
const missing = new Set()
|
|
1141
|
+
for (const row of stmtSelectAll.iterate()) {
|
|
1142
|
+
const snap = rowToSnap(row)
|
|
1143
|
+
if (!snap.destination) continue
|
|
1144
|
+
if (!existsSync(resolveOutputPath(snap.destination))) missing.add(snap.id)
|
|
1145
|
+
}
|
|
1146
|
+
cachedMissingOutputs = missing
|
|
1147
|
+
return missing
|
|
1148
|
+
},
|
|
1149
|
+
|
|
1150
|
+
// Drops the memo. Called at the end of onFinalize; exported on the
|
|
1151
|
+
// surface so a test can force a re-read without a full cycle.
|
|
1152
|
+
forgetMissingOutputs() {
|
|
1153
|
+
cachedMissingOutputs = null
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1064
1156
|
// Walk the output folder against recorded snapshots, returning
|
|
1065
1157
|
// a diff describing missing / mismatched / orphaned /
|
|
1066
1158
|
// unverifiable. Backs `mikser --audit-output`. Pure: no mutations.
|
|
@@ -1442,4 +1534,11 @@ onFinalize(async () => {
|
|
|
1442
1534
|
+ 'a render rewrites its own snapshot, so afterwards the new bytes agree with themselves.',
|
|
1443
1535
|
drifted.length, drifted.length > SHOWN ? `, ${SHOWN} shown` : '')
|
|
1444
1536
|
}
|
|
1537
|
+
|
|
1538
|
+
// This cycle just wrote files and recorded snapshots for them, so the
|
|
1539
|
+
// memoized missing-output set describes a state that no longer exists.
|
|
1540
|
+
// Dropped here rather than at the start of the next cycle because in
|
|
1541
|
+
// watch mode there may not be one for hours, and a stale set held that
|
|
1542
|
+
// long would keep re-dispatching entities it saw as missing.
|
|
1543
|
+
m.forgetMissingOutputs()
|
|
1445
1544
|
})
|
package/src/source.js
CHANGED
|
@@ -81,11 +81,19 @@ const SCAN_CONCURRENCY = 16
|
|
|
81
81
|
// disagree, and the losing combination (empty content + a checksum correct
|
|
82
82
|
// for the finished file) is permanent, because every later sync then
|
|
83
83
|
// short-circuits on "unchanged".
|
|
84
|
-
|
|
84
|
+
// `missingOutputs`, when given, is the set of entity ids whose last render
|
|
85
|
+
// wrote a file that is no longer on disk (manifest.missingOutputIds()). The
|
|
86
|
+
// gate answers "has this INPUT changed", and deleting the output folder does
|
|
87
|
+
// not change any input — so without this, `rm -rf out` followed by a build
|
|
88
|
+
// short-circuits every file here, emits nothing, renders nothing, and reports
|
|
89
|
+
// success over an empty folder. An entity whose output is gone is not
|
|
90
|
+
// unchanged in any sense the caller means.
|
|
91
|
+
export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes, missingOutputs } = {}) {
|
|
85
92
|
const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
|
|
86
93
|
const canGate = !reload
|
|
87
94
|
&& !runtime.options.force
|
|
88
95
|
&& !runtime.catalog?.cacheInvalidated
|
|
96
|
+
&& !missingOutputs?.has(id)
|
|
89
97
|
if (canGate) {
|
|
90
98
|
const priorChecksum = priorChecksums
|
|
91
99
|
? priorChecksums.get(id)
|
|
@@ -422,6 +430,12 @@ export function useSource(core, options) {
|
|
|
422
430
|
// per-file instead of doing per-file SQL lookups. ~14× faster
|
|
423
431
|
// at 14k entities (column projection vs full-entity JSON.parse).
|
|
424
432
|
const priorChecksums = checksumsByCollection(collection)
|
|
433
|
+
// Prefetched once per scan, beside priorChecksums and for the same
|
|
434
|
+
// reason: the gate needs an O(1) answer per file, and this is one
|
|
435
|
+
// pass over the snapshots rather than a stat inside the per-file
|
|
436
|
+
// path. Entities listed here defeat the gate — their input is
|
|
437
|
+
// unchanged but the file they produced is gone.
|
|
438
|
+
const missingOutputs = runtime.manifest?.missingOutputIds() ?? new Set()
|
|
425
439
|
// Parallel register — file read + checksum is I/O-bound. Set
|
|
426
440
|
// additions, scanStats increments, and journal addEntry calls
|
|
427
441
|
// are all single-threaded-JS-atomic so no locking required;
|
|
@@ -429,7 +443,7 @@ export function useSource(core, options) {
|
|
|
429
443
|
// and INSERTs are ~10μs vs ~ms-per-file-read, so the journal
|
|
430
444
|
// is never the bottleneck.
|
|
431
445
|
await pMap(files, async (file) => {
|
|
432
|
-
await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums })
|
|
446
|
+
await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums, missingOutputs })
|
|
433
447
|
if (phase === 'import') updateProgress()
|
|
434
448
|
}, { concurrency: SCAN_CONCURRENCY })
|
|
435
449
|
|
|
@@ -450,7 +464,7 @@ export function useSource(core, options) {
|
|
|
450
464
|
logger.info(scanSummary({ cap, loaded: files.length, ...scanStats }))
|
|
451
465
|
})
|
|
452
466
|
|
|
453
|
-
async function registerFile(file, { logger, action = ACTION.CREATE, scanned, priorChecksums } = {}) {
|
|
467
|
+
async function registerFile(file, { logger, action = ACTION.CREATE, scanned, priorChecksums, missingOutputs } = {}) {
|
|
454
468
|
// stats — when called from the scanHook the outer scan provides
|
|
455
469
|
// a Map to accumulate counts. For onSync (chokidar single-file
|
|
456
470
|
// events) the counter is absent and per-file tally is not
|
|
@@ -483,7 +497,7 @@ export function useSource(core, options) {
|
|
|
483
497
|
}
|
|
484
498
|
}
|
|
485
499
|
|
|
486
|
-
const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
|
|
500
|
+
const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes, missingOutputs })
|
|
487
501
|
if (chksum === null) {
|
|
488
502
|
if (stats) stats.skipped++
|
|
489
503
|
// Never becomes a render task, so it would otherwise be invisible
|