mikser-io 10.9.0 → 10.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.9.0",
3
+ "version": "10.10.0",
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,39 @@ 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
+ for (const marker of markersByDestination.get(entity.destination) ?? []) {
422
+ await rm(marker, { force: true })
423
+ checksumMap.delete(marker)
424
+ }
425
+ markersByDestination.delete(entity.destination)
426
+ }
427
+
390
428
  async function renderPresets(entities, { rendererChanged = new Set() } = {}) {
391
429
  const { presets, assetsMap } = runtime.state.assets
392
430
 
@@ -409,11 +447,7 @@ export function assets(options = {}) {
409
447
  const { options: configOptions } = normalizePresetConfig(
410
448
  options.presets?.[entityPreset]
411
449
  )
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)
450
+ entity.destination = presetDestination(entity, entity.preset)
417
451
  // Two gates sit between a scheduled entity and a render: the
418
452
  // manifest's "its source did not change", and this plugin's
419
453
  // marker. A forced render clears both — a marker at the
@@ -421,6 +455,9 @@ export function assets(options = {}) {
421
455
  // disregard.
422
456
  const forced = rendererChanged.has(entityToRender.id)
423
457
  const ignore = forced ? false : await isPresetRendered(entity)
458
+ // Asked after isPresetRendered, which reads the markers this
459
+ // removes.
460
+ if (!ignore) await forgetPresetMarkers(entity)
424
461
  tasks.push({
425
462
  entity,
426
463
  options: {
@@ -661,12 +698,31 @@ export function assets(options = {}) {
661
698
  })
662
699
 
663
700
  onBeforeRender(async signal => {
664
- const { assetsMap } = runtime.state.assets
701
+ const { assetsMap, presets } = runtime.state.assets
665
702
 
666
703
  checksumMap.clear()
667
- const checksumFiles = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
668
- for (let checksumFile of checksumFiles) {
669
- checksumMap.add(path.join(runtime.options.assetsFolder, checksumFile))
704
+ markersByDestination.clear()
705
+ // Scoped to the preset folders rather than the whole assets tree: the
706
+ // assets folder also carries what the files plugin symlinks into it,
707
+ // and those have no markers and never will. Unscoped, every one of
708
+ // them would read as an interrupted render on every cycle.
709
+ const presetFolders = Object.keys(runtime.state.assets.presets).map(name => `${name}/**/*`)
710
+ const files = presetFolders.length
711
+ ? await globby(presetFolders, { cwd: runtime.options.assetsFolder, onlyFiles: true })
712
+ : []
713
+ const derivatives = new Set()
714
+ for (let file of files) {
715
+ const absolute = path.join(runtime.options.assetsFolder, file)
716
+ if (!file.endsWith('.md5')) {
717
+ derivatives.add(absolute)
718
+ continue
719
+ }
720
+ checksumMap.add(absolute)
721
+ // `<destination>.<revision>.md5` — the same shape the orphan
722
+ // sweep strips to get back to the derivative.
723
+ const destination = absolute.replace(/\.[^.]+\.md5$/, '')
724
+ if (!markersByDestination.has(destination)) markersByDestination.set(destination, [])
725
+ markersByDestination.get(destination).push(absolute)
670
726
  }
671
727
 
672
728
  const entitiesToRender = new Map()
@@ -674,6 +730,45 @@ export function assets(options = {}) {
674
730
  // preset moved, or --render-presets asked for them.
675
731
  const presetMoved = new Set()
676
732
 
733
+ // A derivative with no marker beside it is a render that was killed
734
+ // before it could write one.
735
+ //
736
+ // Nothing else notices. The file is present, so the engine's
737
+ // missing-output path does not fire; the source did not change, so
738
+ // the journal never mentions it; and the marker check that WOULD
739
+ // catch it is only consulted for entities something else already
740
+ // scheduled. So the derivative sits there half-written and every
741
+ // build reports nothing to do — which is the whole reason the marker
742
+ // is now removed before a render rather than merely written after
743
+ // one. Removing it only helps if its absence is read.
744
+ //
745
+ // Empty on any healthy build, and the catalog walk is behind that
746
+ // emptiness: this costs one Set lookup per cycle until something has
747
+ // actually gone wrong.
748
+ const unmarked = new Set()
749
+ for (const derivative of derivatives) {
750
+ if (!markersByDestination.has(derivative)) unmarked.add(derivative)
751
+ }
752
+ if (unmarked.size) {
753
+ for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
754
+ const candidatePresets = await getEntityPresets(candidate)
755
+ const damaged = candidatePresets.filter(name => presets[name]
756
+ && unmarked.has(presetDestination(candidate, presets[name])))
757
+ if (!damaged.length) continue
758
+ assetsMap[candidate.id] ??= candidatePresets
759
+ // Forced past the reuse check as well as scheduled: the file
760
+ // on disk is exactly what must not be trusted, and
761
+ // isPresetRendered has no way to tell half-written bytes from
762
+ // finished ones.
763
+ presetMoved.add(candidate.id)
764
+ entitiesToRender.set(candidate.id, candidate)
765
+ }
766
+ useLogger().warn({ code: 'preset-unfinished', derivatives: [...unmarked], sources: entitiesToRender.size },
767
+ 'Assets: %d derivative(s) have no completed-render marker and are being re-derived. '
768
+ + 'A preset render did not finish — the file left behind is not trustworthy.',
769
+ unmarked.size)
770
+ }
771
+
677
772
  // --render-presets [name]: re-derive though nothing moved.
678
773
  //
679
774
  // 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