mikser-io 10.0.1 → 10.0.3

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.0.1",
3
+ "version": "10.0.3",
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 \u2014 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.",
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,56 @@ 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 it does not register: warm rebuilds of
1126
+ // the 10k perf corpus ran 3.36s median with this against 3.56s
1127
+ // without, i.e. nominally faster, which is only to say the difference
1128
+ // is well inside the run-to-run spread (3.0-4.1 vs 3.5-4.7 over five
1129
+ // runs each). Re-measure with `npm run test:perf` before trusting a
1130
+ // claim that it got slower.
1131
+ //
1132
+ // Resolves destinations through resolveOutputPath, which is what
1133
+ // auditOutput uses. The two must agree — `--audit-output` reporting
1134
+ // files that the build just called unchanged is the symptom, not a
1135
+ // separate diagnostic.
1136
+ // Memoized for the cycle. Two callers ask — the source checksum gate
1137
+ // once per scan, recordedHashes() once per layouts dispatch — and both
1138
+ // ask before anything has rendered, so one walk answers both. Cleared
1139
+ // at the end of onFinalize, which is where this cycle's renders are
1140
+ // recorded and the answer stops being true.
1141
+ missingOutputIds() {
1142
+ if (cachedMissingOutputs) return cachedMissingOutputs
1143
+ const missing = new Set()
1144
+ for (const row of stmtSelectAll.iterate()) {
1145
+ const snap = rowToSnap(row)
1146
+ if (!snap.destination) continue
1147
+ if (!existsSync(resolveOutputPath(snap.destination))) missing.add(snap.id)
1148
+ }
1149
+ cachedMissingOutputs = missing
1150
+ return missing
1151
+ },
1152
+
1153
+ // Drops the memo. Called at the end of onFinalize; exported on the
1154
+ // surface so a test can force a re-read without a full cycle.
1155
+ forgetMissingOutputs() {
1156
+ cachedMissingOutputs = null
1157
+ },
1158
+
1064
1159
  // Walk the output folder against recorded snapshots, returning
1065
1160
  // a diff describing missing / mismatched / orphaned /
1066
1161
  // unverifiable. Backs `mikser --audit-output`. Pure: no mutations.
@@ -1442,4 +1537,11 @@ onFinalize(async () => {
1442
1537
  + 'a render rewrites its own snapshot, so afterwards the new bytes agree with themselves.',
1443
1538
  drifted.length, drifted.length > SHOWN ? `, ${SHOWN} shown` : '')
1444
1539
  }
1540
+
1541
+ // This cycle just wrote files and recorded snapshots for them, so the
1542
+ // memoized missing-output set describes a state that no longer exists.
1543
+ // Dropped here rather than at the start of the next cycle because in
1544
+ // watch mode there may not be one for hours, and a stale set held that
1545
+ // long would keep re-dispatching entities it saw as missing.
1546
+ m.forgetMissingOutputs()
1445
1547
  })
@@ -348,6 +348,17 @@ export function assets(options = {}) {
348
348
  }
349
349
 
350
350
  async function isPresetRendered(entity) {
351
+ // The marker says a render HAPPENED. It does not say the file it
352
+ // produced is still there, and the two come apart the moment anyone
353
+ // deletes a derivative — which is the obvious thing to do when you
354
+ // want one rebuilt. The marker survives, this returned true, and the
355
+ // image never came back on any number of ordinary builds; only
356
+ // --force or a config change brought it round, and --audit-output was
357
+ // the only thing that ever said a file was missing.
358
+ //
359
+ // Checked first and cheaply: one stat, and if the derivative is gone
360
+ // no amount of marker archaeology changes the answer.
361
+ if (!existsSync(entity.destination)) return false
351
362
  let result = false
352
363
  let revisions = []
353
364
  const assetChecksum = `${entity.destination}.${entity.preset.checksum}.md5`
@@ -4,7 +4,7 @@ import { mkdir, symlink, unlink, lstat, realpath } from 'fs/promises'
4
4
  import { globby } from 'globby'
5
5
  import pMap from 'p-map'
6
6
  import { checksumsByCollection } from '../catalog.js'
7
- import { sweepDeleted } from '../source.js'
7
+ import { gateChecksum, sweepDeleted } from '../source.js'
8
8
 
9
9
  export function files(options = {}) {
10
10
  return ({
@@ -187,23 +187,32 @@ export function files(options = {}) {
187
187
 
188
188
  const paths = await globby('**/*', { cwd: runtime.options.filesFolder })
189
189
  trackProgress('Files import', paths.length)
190
- // Bulk-prefetch the catalog's existing (id checksum) map for
191
- // this collection once at scan start, so the gate below reads
192
- // from it per-file instead of doing per-file SQL lookups. Same
193
- // pattern source.js uses for documents/layouts via useSource.
194
- // Without this gate the plugin re-emitted createEntity on every
195
- // cycle for every file regardless of changes, inflating the
196
- // journal with phantom mutations and triggering downstream
197
- // re-dispatch of aggregate layouts whose recorded query deps
198
- // matched the collection.
199
- // --force (and a wiped catalog) must defeat this gate, the
200
- // same way source.js's gateChecksum lets them defeat its own.
201
- // This is a second, independent gate: if it ignores them, no
202
- // amount of forcing re-derives a file's name / meta.url /
203
- // meta.presets, and a catalog holding bad `files` rows has no
204
- // repair path short of deleting them.
205
- const forced = runtime.options.force || runtime.catalog?.cacheInvalidated
190
+ // The SHARED gate, not a copy of it.
191
+ //
192
+ // Without a gate the plugin re-emitted createEntity on every cycle
193
+ // for every file regardless of changes, inflating the journal with
194
+ // phantom mutations and triggering downstream re-dispatch of
195
+ // aggregate layouts whose recorded query deps matched the
196
+ // collection. So it grew one — and then it was a SECOND,
197
+ // independent implementation of the decision source.js already
198
+ // owns, which is how it silently missed the missing-output bypass
199
+ // that was added to the original: an image derivative deleted from
200
+ // the assets folder was never regenerated, because the file it
201
+ // derives from was gated here as unchanged and never reached the
202
+ // assets plugin. Green build, missing image, and only
203
+ // --audit-output said otherwise.
204
+ //
205
+ // Every reason to defeat the gate — --force, a wiped catalog, a
206
+ // recorded output that is gone — now lives in exactly one place,
207
+ // so the next one cannot be added to that place and missed here.
208
+ //
209
+ // priorChecksums is still bulk-prefetched per scan so the gate
210
+ // reads a map instead of doing per-file SQL, and missingOutputs
211
+ // alongside it for the same reason; the manifest memoizes the
212
+ // latter for the cycle, so this shares the walk with useSource's
213
+ // own scans rather than paying for a second one.
206
214
  const priorChecksums = checksumsByCollection(collection)
215
+ const missingOutputs = runtime.manifest?.missingOutputIds() ?? new Set()
207
216
  const scanned = new Set()
208
217
  await pMap(paths, async relativePath => {
209
218
  const { source } = await ensureLink(relativePath)
@@ -217,15 +226,13 @@ export function files(options = {}) {
217
226
  // to re-emit; the sweep asks what still EXISTS, and a file
218
227
  // skipped for being unchanged very much exists.
219
228
  scanned.add(id)
220
- const newChecksum = await checksum(source)
229
+ // Returns the checksum to stamp on the entity, or null when
230
+ // the catalog already has this file unchanged and there is
231
+ // nothing to emit. Progress ticks either way — a gated file
232
+ // was still looked at.
233
+ const newChecksum = await gateChecksum(source, id, { priorChecksums, missingOutputs })
221
234
  updateProgress()
222
- // Gate: if the catalog already has this entity with the same
223
- // checksum, the file hasn't changed since the last cycle.
224
- // Skip emitting a CREATE — the catalog row stays correct,
225
- // the journal stays accurate (mutations = actual changes),
226
- // and downstream aggregate-layout invalidation isn't fired
227
- // spuriously.
228
- if (!forced && priorChecksums.get(id) === newChecksum) return
235
+ if (newChecksum === null) return
229
236
  await createEntity({
230
237
  id,
231
238
  uri,
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
- export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
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