mikser-io 10.9.0 → 10.10.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.9.0",
3
+ "version": "10.10.1",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -118,6 +118,11 @@ export function assets(options = {}) {
118
118
  const collection = 'presets'
119
119
  const type = 'preset'
120
120
  const checksumMap = new Set()
121
+ // Every marker on disk, indexed by the derivative it claims. Built beside
122
+ // checksumMap so forgetting a derivative's markers costs a lookup rather
123
+ // than a scan — this runs once per entity that renders, and a directory
124
+ // walk per entity is quadratic on an assets tree of any size.
125
+ const markersByDestination = new Map()
121
126
 
122
127
  // A preset that matches nothing builds green and says nothing, which is
123
128
  // how a mistyped pattern ships. `files({ outputFolder })` prefixes `name`
@@ -387,6 +392,40 @@ export function assets(options = {}) {
387
392
  return result
388
393
  }
389
394
 
395
+ // A marker outliving the render it describes is the whole defect.
396
+ //
397
+ // `isPresetRendered` is keyed on the SOURCE checksum, and an interrupted
398
+ // render does not change the source — so a derivative that was being
399
+ // rewritten when the process died keeps a marker that still matches, and
400
+ // every later build reports nothing to do over a half-written file.
401
+ // Measured on a 20KB fixture: delete a derivative, interrupt the
402
+ // re-render, and the site serves 10KB of it indefinitely.
403
+ //
404
+ // The marker means "a completed render produced this file", so it is
405
+ // removed before the render starts and written again by onComplete. An
406
+ // interrupted render then leaves no claim behind and the next ordinary
407
+ // build re-derives. The FILE is deliberately left alone: a preset that
408
+ // fails before writing anything still has a good derivative on disk, and
409
+ // removing it would take a working asset off the site until the next
410
+ // build.
411
+ // Where a preset puts a given entity. One implementation, because the
412
+ // reuse check, the render dispatch and the unmarked-derivative scan all
413
+ // need it and a second copy would drift from `presetUrl` the moment a
414
+ // preset grew an option.
415
+ function presetDestination(entity, preset) {
416
+ const name = preset.format ? changeExtension(entity.name, preset.format) : entity.name
417
+ return path.join(runtime.options.assetsFolder, preset.name, name)
418
+ }
419
+
420
+ async function forgetPresetMarkers(entity) {
421
+ const destination = path.resolve(entity.destination)
422
+ for (const marker of markersByDestination.get(destination) ?? []) {
423
+ await rm(marker, { force: true })
424
+ checksumMap.delete(marker)
425
+ }
426
+ markersByDestination.delete(destination)
427
+ }
428
+
390
429
  async function renderPresets(entities, { rendererChanged = new Set() } = {}) {
391
430
  const { presets, assetsMap } = runtime.state.assets
392
431
 
@@ -409,11 +448,7 @@ export function assets(options = {}) {
409
448
  const { options: configOptions } = normalizePresetConfig(
410
449
  options.presets?.[entityPreset]
411
450
  )
412
- let destination = entity.name
413
- if (entity.preset.format) {
414
- destination = changeExtension(destination, entity.preset.format)
415
- }
416
- entity.destination = path.join(runtime.options.assetsFolder, entityPreset, destination)
451
+ entity.destination = presetDestination(entity, entity.preset)
417
452
  // Two gates sit between a scheduled entity and a render: the
418
453
  // manifest's "its source did not change", and this plugin's
419
454
  // marker. A forced render clears both — a marker at the
@@ -421,6 +456,9 @@ export function assets(options = {}) {
421
456
  // disregard.
422
457
  const forced = rendererChanged.has(entityToRender.id)
423
458
  const ignore = forced ? false : await isPresetRendered(entity)
459
+ // Asked after isPresetRendered, which reads the markers this
460
+ // removes.
461
+ if (!ignore) await forgetPresetMarkers(entity)
424
462
  tasks.push({
425
463
  entity,
426
464
  options: {
@@ -661,12 +699,26 @@ export function assets(options = {}) {
661
699
  })
662
700
 
663
701
  onBeforeRender(async signal => {
664
- const { assetsMap } = runtime.state.assets
702
+ const { assetsMap, presets } = runtime.state.assets
665
703
 
666
704
  checksumMap.clear()
705
+ markersByDestination.clear()
667
706
  const checksumFiles = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
668
707
  for (let checksumFile of checksumFiles) {
669
- checksumMap.add(path.join(runtime.options.assetsFolder, checksumFile))
708
+ const marker = path.join(runtime.options.assetsFolder, checksumFile)
709
+ checksumMap.add(marker)
710
+ // `<destination>.<revision>.md5` — the same shape the orphan
711
+ // sweep strips to get back to the derivative.
712
+ //
713
+ // Resolved on both sides of the comparison, because the other
714
+ // side is a manifest destination and these two are built by
715
+ // different subsystems. It is a no-op while assetsFolder is
716
+ // derived from workingFolder (see onLoaded), which is why no test
717
+ // can tell it apart — normalization at the boundary, not a
718
+ // transform anything depends on.
719
+ const destination = path.resolve(marker.replace(/\.[^.]+\.md5$/, ''))
720
+ if (!markersByDestination.has(destination)) markersByDestination.set(destination, [])
721
+ markersByDestination.get(destination).push(marker)
670
722
  }
671
723
 
672
724
  const entitiesToRender = new Map()
@@ -674,6 +726,80 @@ export function assets(options = {}) {
674
726
  // preset moved, or --render-presets asked for them.
675
727
  const presetMoved = new Set()
676
728
 
729
+ // A derivative with no marker beside it is a render that was killed
730
+ // before it could write one.
731
+ //
732
+ // Nothing else notices. The file is present, so the engine's
733
+ // missing-output path does not fire; the source did not change, so
734
+ // the journal never mentions it; and the marker check that WOULD
735
+ // catch it is only consulted for entities something else already
736
+ // scheduled. So the derivative sits there half-written and every
737
+ // build reports nothing to do — which is the whole reason the marker
738
+ // is now removed before a render rather than merely written after
739
+ // one. Removing it only helps if its absence is read.
740
+ //
741
+ // Empty on any healthy build, and the catalog walk is behind that
742
+ // emptiness: this costs one Set lookup per cycle until something has
743
+ // actually gone wrong.
744
+ // Asked of the MANIFEST, not of the folder.
745
+ //
746
+ // The first version walked every file under each preset folder and
747
+ // called anything without a marker an interrupted render. But only
748
+ // the destination the engine hands the preset gets a marker, so a
749
+ // preset that legitimately writes MORE than one file — a poster frame
750
+ // beside a video — left permanent evidence of a failure that never
751
+ // happened. It warned on every build for ever, said the derivative
752
+ // was "being re-derived", and re-derived nothing, because no entity's
753
+ // destination ever matched the extra file. A warning that cries wolf
754
+ // on every build is worse than the silence it replaced, and it
755
+ // teaches people to ignore the one that matters.
756
+ //
757
+ // A file nothing claims is an ORPHAN, which is a different fault with
758
+ // its own report. This asks only about outputs the manifest recorded,
759
+ // which is also what makes the recovery possible: a snapshot carries
760
+ // the entity id, so there is nothing to reverse-map and no catalog to
761
+ // walk.
762
+ const presetRoots = Object.keys(presets)
763
+ .map(name => path.resolve(runtime.options.assetsFolder, name) + path.sep)
764
+ const unfinished = new Map()
765
+ for (const snapshot of runtime.manifest?.all?.() ?? []) {
766
+ if (!snapshot.destination) continue
767
+ const destination = path.resolve(snapshot.destination)
768
+ if (!presetRoots.some(root => destination.startsWith(root))) continue
769
+ if (markersByDestination.has(destination)) continue
770
+ // Gone entirely is a different fault, and the engine's
771
+ // missing-output path already has it. Reporting it here as well
772
+ // would name the wrong cause.
773
+ if (outputMissing(snapshot.destination)) continue
774
+ unfinished.set(snapshot.id, destination)
775
+ }
776
+ if (unfinished.size) {
777
+ const damaged = []
778
+ for (const [id, destination] of unfinished) {
779
+ const entity = await findEntity({ id })
780
+ // Source gone: the orphan sweep removes the derivative at
781
+ // finalize. Nothing to re-derive and nothing to say.
782
+ if (!entity) continue
783
+ assetsMap[entity.id] ??= await getEntityPresets(entity)
784
+ // Forced past the reuse check as well as scheduled: the file
785
+ // on disk is exactly what must not be trusted, and
786
+ // isPresetRendered cannot tell half-written bytes from
787
+ // finished ones.
788
+ presetMoved.add(entity.id)
789
+ entitiesToRender.set(entity.id, entity)
790
+ damaged.push(destination)
791
+ }
792
+ // Only when something is actually being re-derived. The warning
793
+ // claimed the action rather than reporting it, so a build that
794
+ // scheduled nothing still announced a recovery.
795
+ if (damaged.length) {
796
+ useLogger().warn({ code: 'preset-unfinished', derivatives: damaged },
797
+ 'Assets: %d derivative(s) have no completed-render marker and are being re-derived. '
798
+ + 'A preset render did not finish — the file left behind is not trustworthy.',
799
+ damaged.length)
800
+ }
801
+ }
802
+
677
803
  // --render-presets [name]: re-derive though nothing moved.
678
804
  //
679
805
  // The escape hatch for what the incremental machinery cannot see — a
@@ -1,4 +1,4 @@
1
- import { mkdir } from 'node:fs/promises'
1
+ import { mkdir, rm, stat } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  // A renderer's `load` runs for EVERY entity in the cycle, not only the ones
@@ -23,9 +23,42 @@ export async function load({ entity, runtime }) {
23
23
  runtime.preset = preset.default
24
24
  }
25
25
 
26
+ async function describe(file) {
27
+ try {
28
+ const { mtimeMs, size } = await stat(file)
29
+ return { mtimeMs, size }
30
+ } catch { return null }
31
+ }
32
+
33
+ // A render that failed leaves nothing half-written behind.
34
+ //
35
+ // This is the one place user code is handed a final output path and left to
36
+ // fill it, and a half-written derivative there is not self-correcting. The
37
+ // marker `isPresetRendered` consults is keyed on the SOURCE checksum, and an
38
+ // interruption does not change the source; the file exists, so the engine's
39
+ // missing-output path does not fire either. Both gates therefore say "nothing
40
+ // to do" over a truncated file, for as long as nobody looks. Measured on a
41
+ // 20KB fixture: interrupt one re-render and every later build serves 10KB of
42
+ // it. Worse where the preset wraps a tool whose non-zero exit it does not
43
+ // check — it RESOLVES, the manifest snapshots the truncated bytes, and
44
+ // --audit-output then reads green, because it compares each output against
45
+ // the hash its own render recorded.
46
+ //
47
+ // Removed only when THIS render is what changed it. A preset that fails
48
+ // before writing anything still has a good derivative on disk, and deleting
49
+ // that would take a working asset off the site until the next build — so the
50
+ // file is measured before and after, and left alone if it did not move.
26
51
  export async function render({ entity, options, config, context, plugins, runtime, state, logger }) {
27
52
  await mkdir(path.dirname(entity.destination), { recursive: true })
28
- await runtime.preset({ entity, options, config, context, plugins, runtime, state, logger })
53
+ const before = await describe(entity.destination)
54
+ try {
55
+ await runtime.preset({ entity, options, config, context, plugins, runtime, state, logger })
56
+ } catch (error) {
57
+ const after = await describe(entity.destination)
58
+ const touched = after && (!before || after.size !== before.size || after.mtimeMs !== before.mtimeMs)
59
+ if (touched) await rm(entity.destination, { force: true })
60
+ throw error
61
+ }
29
62
  return entity.destination
30
63
  }
31
64