mikser-io 10.0.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.0.0",
3
+ "version": "10.0.2",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -96,5 +96,9 @@
96
96
  "bugs": {
97
97
  "url": "https://github.com/almero-digital-marketing/mikser-io/issues"
98
98
  },
99
- "homepage": "https://github.com/almero-digital-marketing/mikser-io#readme"
99
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io#readme",
100
+ "allowScripts": {
101
+ "better-sqlite3": true,
102
+ "sharp": true
103
+ }
100
104
  }
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
- 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