mikser-io 10.0.4 → 10.2.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/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { default as runtime } from './src/runtime.js'
2
2
  export * as constants from './src/constants.js'
3
3
  export * from './src/utils.js'
4
+ export * from './src/invalidation.js'
4
5
  export * from './src/auth.js'
5
6
  export * from './src/roles.js'
6
7
  export * from './src/inventory.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.0.4",
3
+ "version": "10.2.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -48,7 +48,7 @@ import Database from 'better-sqlite3'
48
48
  import runtime from '../runtime.js'
49
49
  import { isReportOnlyRun } from '../tools.js'
50
50
  import { reportWipe } from '../report.js'
51
- import { onLoaded } from '../lifecycle.js'
51
+ import { onLoaded, onFinalized } from '../lifecycle.js'
52
52
  import packageInfo from '../../package.json' with { type: 'json' }
53
53
 
54
54
  // Local logger resolver — same one-liner engine.js exports as
@@ -242,6 +242,10 @@ export function createSqliteDatabase({
242
242
  // `--clear` asks for. Removes the file; nothing durable is in it.
243
243
  forceWipe = false,
244
244
  }) {
245
+ // Set at open, written at the first successful finalize. See the note
246
+ // where it is assigned.
247
+ let pendingStamp = null
248
+
245
249
  // Tests inject their own provisioners; the runtime path falls
246
250
  // through to the module-level `provisioners` array that plugins
247
251
  // populate via onProvision() at module-eval.
@@ -391,9 +395,24 @@ export function createSqliteDatabase({
391
395
  handle = new Database(dbPath)
392
396
  setupConnection()
393
397
  }
394
- const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
395
- stmtStamp.run('schema_version', version)
396
- if (currentConfig) stmtStamp.run('config_checksum', currentConfig)
398
+ // Stamped when a cycle FINISHES, not when the database opens.
399
+ //
400
+ // The stamp is the only record of "this cache was rebuilt for this
401
+ // version", and writing it at open made it a record of "this cache
402
+ // was opened", which is a different and much weaker claim. A wipe
403
+ // followed by an interrupted rebuild left the version current and the
404
+ // cache half-built: the import had finished, so the catalog matched
405
+ // disk, but nothing had rendered, so there were no snapshots and the
406
+ // output folder still held the previous build. Every later start read
407
+ // a matching version, wiped nothing, correctly reported "N unchanged"
408
+ // and rendered nothing — a site frozen on the last good output, green
409
+ // on every signal, recoverable only by --force.
410
+ //
411
+ // Deferred, the same sequence self-heals: the interrupted run leaves
412
+ // no stamp, so the next start sees a mismatch and rebuilds properly.
413
+ // config_checksum goes with it for the same reason — a config change
414
+ // that half-applied must not look applied.
415
+ pendingStamp = { version, config: currentConfig }
397
416
 
398
417
  // Build provisioning context. firstRun is true when the file
399
418
  // didn't exist before this open OR when the schema mismatch
@@ -444,6 +463,43 @@ export function createSqliteDatabase({
444
463
  }
445
464
  }
446
465
 
466
+ // Did the last rebuild finish?
467
+ //
468
+ // Now that the stamp means "a cycle completed", its ABSENCE beside a
469
+ // populated catalog is a fact worth acting on: entities were imported
470
+ // and nothing ever finalized. The catalog matches disk, so every gate
471
+ // that reasons about inputs correctly says "unchanged"; the manifest
472
+ // is empty, so nothing has been rendered; and the output folder still
473
+ // holds whatever the last complete build wrote, because a cache wipe
474
+ // unlinks the database and nothing else.
475
+ //
476
+ // No layer can see this on its own. The source gate's evidence is
477
+ // sound, the dispatcher seeds from a journal that was discarded with
478
+ // the interrupted run, and the render gate — which would answer
479
+ // `never-rendered` — is never asked, because nothing dispatches to it.
480
+ // So it is declared as an override, and invalidation.js hands it to
481
+ // every gate at once.
482
+ //
483
+ // An empty catalog here is an ordinary first run or a completed wipe:
484
+ // nothing to reconcile, and emptiness already opens every gate.
485
+ if (!handle.prepare('SELECT value FROM mikser_meta WHERE key = ?').get('schema_version')) {
486
+ const table = handle.prepare(
487
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='mikser_entities'").get()
488
+ const entities = table
489
+ ? handle.prepare('SELECT count(*) AS count FROM mikser_entities').get()?.count ?? 0
490
+ : 0
491
+ if (entities > 0) {
492
+ logger?.warn(
493
+ 'The last rebuild did not finish — %d entities were imported and no cycle completed, '
494
+ + 'so the catalog describes your sources while nothing has been rendered from them and '
495
+ + 'the output folder still holds the previous build. Rebuilding everything on this run. '
496
+ + '(A cache wipe followed by a restart mid-cycle does this; without the check the site '
497
+ + 'stays on the old output and every build reports "unchanged".)',
498
+ entities,
499
+ )
500
+ runtime.options.cacheRebuildInterrupted = true
501
+ }
502
+ }
447
503
  }
448
504
 
449
505
  function close() {
@@ -467,6 +523,17 @@ export function createSqliteDatabase({
467
523
  path: dbPath,
468
524
  open,
469
525
  close,
526
+ // Called once a cycle has finalized — see pendingStamp. Idempotent:
527
+ // the second cycle of a watch run has nothing left to write.
528
+ commitStamp() {
529
+ if (!pendingStamp || !handle) return false
530
+ const stamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
531
+ stamp.run('schema_version', pendingStamp.version)
532
+ if (pendingStamp.config) stamp.run('config_checksum', pendingStamp.config)
533
+ pendingStamp = null
534
+ runtime.options.cacheRebuildInterrupted = false
535
+ return true
536
+ },
470
537
  get isOpen() { return handle !== null },
471
538
  // Provisioning context from the most recent open. Plugins'
472
539
  // onLoaded handlers can read this without subscribing to
@@ -492,6 +559,20 @@ export function createSqliteDatabase({
492
559
  }
493
560
  }
494
561
 
562
+ // The cache is stamped for this version only once a cycle has run to the
563
+ // end. Registered at module level rather than per-open so a watch server
564
+ // that re-opens nothing still stamps its first completed cycle, and so the
565
+ // hook cannot accumulate across opens.
566
+ //
567
+ // onFinalized, not onFinalize: the manifest writes this cycle's snapshots in
568
+ // onFinalize, and the stamp claims that work happened. Claiming it one hook
569
+ // early would reintroduce the failure in miniature.
570
+ onFinalized(async () => {
571
+ if (db?.commitStamp?.()) {
572
+ useLogger()?.debug('Cache stamped — a full cycle completed for this schema version')
573
+ }
574
+ })
575
+
495
576
  onLoaded(async () => {
496
577
  if (db?.isOpen) return // multi-cycle watch mode — keep the open connection
497
578
 
package/src/engine.js CHANGED
@@ -1575,10 +1575,34 @@ The full version, with what each code means: docs/diagnostics.md`)
1575
1575
  // undefined here. The logger has no such problem — it writes during the
1576
1576
  // run, by which time options exist.
1577
1577
  const quietStdout = ['--json', '--tool', '--tools'].some(flag => process.argv.includes(flag))
1578
+ // Through the logger when nobody is watching, so it gets a timestamp.
1579
+ //
1580
+ // This line marks a process start, which in a supervisor's log is the
1581
+ // most useful thing on the page — it is how a restart is found at all.
1582
+ // Written straight to the stream it was undated and, worse, carried its
1583
+ // own hardcoded escapes into a file that no terminal would ever render.
1584
+ //
1585
+ // The decorated form stays for a terminal, where it is a banner rather
1586
+ // than a record.
1578
1587
  if (runtime.options?.json || quietStdout) {
1588
+ // Written straight to stderr, NOT through the logger: the logger picks
1589
+ // its sink per-write from runtime.options.json, which commander has
1590
+ // not parsed yet — the same reason quietStdout reads argv above. A
1591
+ // banner routed through it here lands on stdout and turns the
1592
+ // document into a parse error, which is the failure this branch was
1593
+ // added to prevent in the first place.
1579
1594
  process.stderr.write(`mikser. ${packageInfo.version}\n`)
1580
- } else {
1595
+ } else if (process.stdout.isTTY && !process.env.NO_COLOR) {
1581
1596
  console.info('\x1b[1mmikser\x1b[22;5;38;2;255;63;0m.\x1b[0m %s\n', packageInfo.version)
1597
+ } else {
1598
+ // Redirected: through the logger, so the line that marks a process
1599
+ // start carries a timestamp. In a supervisor's log that is the most
1600
+ // useful line on the page — it is how a restart is found at all — and
1601
+ // written straight to the stream it was undated and carried its own
1602
+ // escapes into a file no terminal will render.
1603
+ const logger = useLogger()
1604
+ if (logger) logger.info('mikser. %s', packageInfo.version)
1605
+ else process.stdout.write(`mikser. ${packageInfo.version}\n`)
1582
1606
  }
1583
1607
  return runtime
1584
1608
  }
package/src/explain.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // Follows --audit-output's shape: report and exit, no build phases run.
11
11
  import { existsSync } from 'node:fs'
12
12
  import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
13
- import { resolveOutputPath } from './manifest.js'
13
+ import { outputMissing } from './invalidation.js'
14
14
  import { filterKey } from './track.js'
15
15
  import { findEntity, findEntities, findById } from './catalog.js'
16
16
  import runtime from './runtime.js'
@@ -141,7 +141,7 @@ export async function explain(reference) {
141
141
  // attempt — if the last render threw, that is the more useful sentence,
142
142
  // and it explains the absence too.
143
143
  const missingDestinations = snapshots
144
- .filter(snap => snap.destination && !existsSync(resolveOutputPath(snap.destination)))
144
+ .filter(snap => outputMissing(snap.destination))
145
145
  .map(snap => snap.destination)
146
146
 
147
147
  // The catalog is as of the LAST BUILD. If the file has been edited since,
@@ -244,9 +244,7 @@ export async function explain(reference) {
244
244
  // re-renders. --explain contradicting the build is worse than
245
245
  // --explain being incomplete, because it is the tool someone
246
246
  // reaches for when the build has already surprised them.
247
- missing: snap.destination
248
- ? !existsSync(resolveOutputPath(snap.destination))
249
- : false,
247
+ missing: outputMissing(snap.destination),
250
248
  outputHash: snap.outputHash ?? null,
251
249
  parent: snap.parent ?? null,
252
250
  // Which keys of its OWN meta the render read, and which keys it
@@ -0,0 +1,165 @@
1
+ // Should this work be redone?
2
+ //
3
+ // One question, asked at four layers with different evidence in hand:
4
+ //
5
+ // source.js a file's checksum against the catalog's
6
+ // manifest a recorded hash, for the layouts dispatcher's seeding
7
+ // manifest a snapshot plus its refClosure, at the render gate
8
+ // assets a `.md5` marker at the current preset revision
9
+ //
10
+ // The evidence differs and belongs to each layer. What does NOT differ is
11
+ // what OVERRIDES the evidence — and that is what this module owns, because
12
+ // it was written four times and drifted the moment a fifth override was
13
+ // added to one of them.
14
+ //
15
+ // The drift, concretely: `rm -rf out` changes no input, so every layer
16
+ // answered "unchanged" and a build rendered nothing, printed no `Rendered:`
17
+ // line and exited 0 over an empty output folder. Adding an output-existence
18
+ // check to the render gate fixed nothing, because the source gate had
19
+ // already dropped the entity; adding it there fixed the site but not image
20
+ // derivatives, because files.js held a private copy of that gate; and fixing
21
+ // that still left the assets marker, which had never asked about the file at
22
+ // all. Five gates, one question, four implementations of the override rules.
23
+ //
24
+ // So the rule is: a layer may own what it KNOWS. It may not own what
25
+ // overrides it. Anything that makes a gate not apply — --force, a wiped
26
+ // cache, a reload event, an output that is gone — is declared here once, and
27
+ // a new one added here reaches every gate that ever asks.
28
+ //
29
+ // Deliberately NOT moved here: the dependency-graph walk in
30
+ // manifest.skipDecision. That reasons over snapshots, refClosures and sift
31
+ // filters the manifest owns, and hauling it out would trade a real coupling
32
+ // for a fake one. It asks this module what overrides its answer, which is the
33
+ // part that was duplicated.
34
+ //
35
+ // A leaf module: runtime and node builtins only. It reads `runtime.manifest`
36
+ // lazily rather than importing it, the way every other consumer of engine
37
+ // state does, so the manifest can import this without a cycle.
38
+
39
+ import path from 'node:path'
40
+ import { existsSync } from 'node:fs'
41
+ import runtime from './runtime.js'
42
+
43
+ // The vocabulary, in one place because it is asserted against.
44
+ //
45
+ // `--json` carries these strings out to callers and the scenario suite
46
+ // matches on them, so they are API. Defined here rather than in the gate
47
+ // that happens to emit each one, so the set can be read as a set.
48
+ export const REASON = Object.freeze({
49
+ // Overrides — this module's business.
50
+ FORCE: 'force',
51
+ CACHE_INVALIDATED: 'cache-invalidated',
52
+ RELOAD: 'reload',
53
+ REBUILD_INTERRUPTED: 'rebuild-interrupted',
54
+ OUTPUT_MISSING: 'output-missing',
55
+ // Evidence — each layer's business, named here so the vocabulary is
56
+ // legible as a whole.
57
+ UNCHANGED: 'unchanged',
58
+ NEVER_RENDERED: 'never-rendered',
59
+ INPUTS_CHANGED: 'inputs-changed',
60
+ REF_CHANGED: 'ref-changed',
61
+ QUERY_MATCHED: 'query-matched',
62
+ CACHE_DISABLED: 'cache-disabled',
63
+ RETRY_FAILED: 'retry-failed',
64
+ })
65
+
66
+ // Where an output lives, given what a snapshot recorded.
67
+ //
68
+ // Destinations come in two shapes and both are legitimate: output-relative
69
+ // for a rendered page (`/index.html`), absolute for a derivative the assets
70
+ // plugin places itself. Resolving relative-first and falling back to the
71
+ // literal path covers both without the caller having to know which it holds.
72
+ //
73
+ // Returns the joined path even when nothing exists there, so a caller can
74
+ // report WHICH path it looked for — "missing" without a path is a fact
75
+ // nobody can act on.
76
+ export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
77
+ if (!destination) return undefined
78
+ const joined = path.join(outputFolder ?? '', destination)
79
+ if (existsSync(joined)) return joined
80
+ if (path.isAbsolute(destination) && existsSync(destination)) return destination
81
+ return joined
82
+ }
83
+
84
+ // Is the file this destination names gone?
85
+ export function outputMissing(destination) {
86
+ if (!destination) return false
87
+ return !existsSync(resolveOutputPath(destination))
88
+ }
89
+
90
+ // Memoized for the cycle.
91
+ //
92
+ // Every gate asks, and they all ask before anything has rendered, so one walk
93
+ // answers all of them. Dropped at the end of onFinalize, where this cycle's
94
+ // renders are recorded and the answer stops being true — not at the start of
95
+ // the next cycle, because in watch mode there may not be one for hours and a
96
+ // stale set held that long would re-dispatch entities it saw as missing.
97
+ let cachedMissingOutputs = null
98
+
99
+ // The entity ids whose last render wrote a file that is no longer on disk.
100
+ //
101
+ // One stat per recorded snapshot. The syscalls are ~2.2ms per 1842 paths and
102
+ // cost the same whether the files are there or not; at the build level it does
103
+ // not register — warm rebuilds of the 10k perf corpus ran 3.36s median with
104
+ // this against 3.56s without, i.e. nominally faster, which is only to say the
105
+ // difference is inside the run-to-run spread. Re-measure with
106
+ // `npm run test:perf` before trusting a claim that it got slower.
107
+ export function missingOutputIds() {
108
+ if (cachedMissingOutputs) return cachedMissingOutputs
109
+ const missing = new Set()
110
+ for (const snapshot of runtime.manifest?.all?.() ?? []) {
111
+ if (!snapshot.destination) continue
112
+ if (outputMissing(snapshot.destination)) missing.add(snapshot.id)
113
+ }
114
+ cachedMissingOutputs = missing
115
+ return missing
116
+ }
117
+
118
+ export function forgetMissingOutputs() {
119
+ cachedMissingOutputs = null
120
+ }
121
+
122
+ // What overrides a gate's own evidence, or null when nothing does.
123
+ //
124
+ // Callers pass the evidence they hold: `reload` if they are a watch event,
125
+ // `id` if they are gating an entity whose outputs are recorded. Passing
126
+ // neither asks only the universal question, which is what the render gate
127
+ // wants — it checks the output separately and later, so that an entity whose
128
+ // inputs ALSO moved reports `inputs-changed` rather than losing that detail
129
+ // to a broader answer.
130
+ export function bypassReason({ reload = false, id } = {}) {
131
+ if (reload) return REASON.RELOAD
132
+ if (runtime.options?.force) return REASON.FORCE
133
+ if (runtime.catalog?.cacheInvalidated) return REASON.CACHE_INVALIDATED
134
+ // The previous rebuild imported entities and never finalized, so the
135
+ // catalog describes sources that nothing has rendered. Set by
136
+ // database/index.js at open, cleared when a cycle stamps the cache.
137
+ // Declared here so it reaches every gate — which is the whole reason
138
+ // this module exists, and this is the first override added since.
139
+ if (runtime.options?.cacheRebuildInterrupted) return REASON.REBUILD_INTERRUPTED
140
+ if (id !== undefined && missingOutputIds().has(id)) return REASON.OUTPUT_MISSING
141
+ return null
142
+ }
143
+
144
+ // Is every entity being presented for evaluation this cycle?
145
+ //
146
+ // The same fact as bypassReason, asked for a different purpose: a "declared X
147
+ // matched nothing" warning is only worth printing when everything was looked
148
+ // at. On an incremental cycle a pattern legitimately matches nothing in a run
149
+ // of two, and a warning that always fires is one people filter out — taking
150
+ // the real instance with it.
151
+ //
152
+ // `firstRun` counts here and not in bypassReason because there is nothing to
153
+ // bypass on a first run: no prior checksum, no snapshot, so every gate opens
154
+ // on its own evidence.
155
+ //
156
+ // Takes the runtime rather than reading the singleton, so a plugin passes the
157
+ // one it was injected with — the singleton in a real build, the harness's
158
+ // stand-in under test.
159
+ export function isFullCycle(engineRuntime = runtime) {
160
+ return !!(
161
+ engineRuntime?.options?.force
162
+ || engineRuntime?.options?.firstRun
163
+ || engineRuntime?.catalog?.cacheInvalidated
164
+ )
165
+ }
package/src/logger.js CHANGED
@@ -154,16 +154,44 @@ function createTerminalStream() {
154
154
  // trace} resolves to, defaulting to 'info'.
155
155
  export function createMikserLogger(level = 'info') {
156
156
  const terminalStream = createTerminalStream()
157
+
158
+ // A terminal is watched as it happens. A file is read afterwards.
159
+ //
160
+ // The minimal format below is right for the first and wrong for the
161
+ // second, and until now it was used for both — so a supervisor's log was
162
+ // a wall of undated lines wearing ANSI escapes. Reconstructing an
163
+ // incident from one meant ordering events by file mtimes and git commit
164
+ // dates because the build's own log could not say when anything happened,
165
+ // and every excerpt had to be piped through sed to be readable.
166
+ //
167
+ // Decided from the stream these lines actually land on, which is stderr
168
+ // under --json / --tool (stdout carries the document there). The logger is
169
+ // rebuilt at onLoad, by which point those options are parsed, so the
170
+ // second construction gets it right even if the first cannot.
171
+ //
172
+ // Same signal the progress bar already uses — a gauge is pointless in a
173
+ // file for the same reason a timestamp is pointless on a terminal.
174
+ const target = (runtime.options?.json || runtime.options?.tool || runtime.options?.tools)
175
+ ? process.stderr : process.stdout
176
+ const attended = Boolean(target.isTTY)
177
+
157
178
  const prettyStream = pretty({
158
179
  destination: terminalStream,
159
- colorize: true,
160
- // apt-like minimal format: hide timestamp / pid / hostname, and
161
- // suppress the level prefix entirely via a customPrettifier
162
- // that returns an empty string. The icon prepended by
163
- // messageFormat (🟡 / 🔴 / 🟢 / …) is what signals level. The
164
- // raw pino record still carries `level`, so third-party
165
- // transports get full structured data.
166
- ignore: 'pid,hostname,time',
180
+ // NO_COLOR is honoured on a terminal too; nobody wants escapes in a
181
+ // file regardless.
182
+ colorize: attended && !process.env.NO_COLOR,
183
+ // `SYS:standard` carries the date, milliseconds AND the UTC offset.
184
+ // The offset is not decoration: the incident that prompted this
185
+ // needed a container's clock lined up against commit dates in
186
+ // another zone, and a bare wall-clock time cannot answer that.
187
+ ...(attended ? {} : { translateTime: 'SYS:standard' }),
188
+ // apt-like minimal format: hide pid / hostname, and suppress the
189
+ // level prefix entirely via a customPrettifier that returns an empty
190
+ // string. The icon prepended by messageFormat (🟡 / 🔴 / 🟢 / …) is
191
+ // what signals level. The raw pino record still carries `level`, so
192
+ // third-party transports get full structured data. `time` is dropped
193
+ // only when someone is watching.
194
+ ignore: attended ? 'pid,hostname,time' : 'pid,hostname',
167
195
  // The terminal gets the SENTENCE; the structured fields go to the
168
196
  // report and to transports.
169
197
  //
package/src/manifest.js CHANGED
@@ -60,6 +60,9 @@ import { extractRefs, inputHashOf, inputPartsOf, diffInputParts, lookupKeys } fr
60
60
  import { filterKey } from './track.js'
61
61
  import { findById, findEntities } from './catalog.js'
62
62
  import { useDatabase, registerSchema } from './database/index.js'
63
+ import {
64
+ REASON, bypassReason, resolveOutputPath, outputMissing, missingOutputIds, forgetMissingOutputs,
65
+ } from './invalidation.js'
63
66
 
64
67
  // Schema registration. Applied at db.open(). PRIMARY KEY (id,
65
68
  // destination) — leading id column means `WHERE id = ?` queries use the
@@ -284,13 +287,6 @@ function snapToRow(snap) {
284
287
  // becomes two symptoms — one loud (every asset reported missing, at its
285
288
  // real and present path) and one silent (no outputHash recorded, leaving
286
289
  // most snapshots presence-checked only).
287
- export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
288
- if (!destination) return undefined
289
- const joined = path.join(outputFolder ?? '', destination)
290
- if (existsSync(joined)) return joined
291
- if (path.isAbsolute(destination) && existsSync(destination)) return destination
292
- return joined
293
- }
294
290
 
295
291
  async function hashOutputFile(destination) {
296
292
  const filePath = resolveOutputPath(destination)
@@ -372,11 +368,6 @@ export async function sourcesOf(destination) {
372
368
  export function createManifest(db) {
373
369
  if (!db) throw new Error('createManifest: db is required')
374
370
 
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
-
380
371
  const stmtCollisions = db.prepare(`
381
372
  SELECT destination, count(*) AS n, group_concat(id) AS ids
382
373
  FROM mikser_snapshots
@@ -682,8 +673,12 @@ export function createManifest(db) {
682
673
  // That is the situation --force exists for — the invalidation
683
674
  // graph being under suspicion — including where the preset
684
675
  // no-match warning tells the operator to use it.
685
- if (runtime.options?.force) return { skip: false, reason: 'force' }
686
- if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
676
+ // --force and a wiped cache both mean "ignore what you think you
677
+ // know", and both are declared in invalidation.js so a third one
678
+ // added there reaches this gate without being remembered.
679
+ const override = bypassReason()
680
+ if (override) return { skip: false, reason: override }
681
+ if (entity?.meta?.cache === false) return { skip: false, reason: REASON.CACHE_DISABLED }
687
682
  // A render whose last attempt threw must be retried, and checked
688
683
  // before anything else: every other branch reasons about hashes,
689
684
  // and the hashes are consistent — the entity did not change, the
@@ -737,15 +732,13 @@ export function createManifest(db) {
737
732
  // rather than in the render loop because this is the function that
738
733
  // owns the word: anything asking shouldSkip() gets the same answer.
739
734
  //
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 }
735
+ // Asked of invalidation.js rather than answered here, so that
736
+ // auditOutput, the source gate and the assets marker resolve a
737
+ // destination the same way. The two disagreeing is the bug
738
+ // `--audit-output` finding what the build just called clean is
739
+ // what sends people to this function in the first place.
740
+ if (outputMissing(snapshot.destination)) {
741
+ return { skip: false, reason: REASON.OUTPUT_MISSING, destination: snapshot.destination }
749
742
  }
750
743
  if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
751
744
  const sourceLang = entity?.meta?.lang ?? null
@@ -1031,7 +1024,7 @@ export function createManifest(db) {
1031
1024
  // rediscover the same caveat. missingOutputIds() is memoized for the
1032
1025
  // cycle, so this shares the walk the source gate already paid for.
1033
1026
  recordedHashes() {
1034
- const missingOutputs = this.missingOutputIds()
1027
+ const missingOutputs = missingOutputIds()
1035
1028
  const map = new Map()
1036
1029
  for (const row of stmtEntityInputHashes.iterate()) {
1037
1030
  if (missingOutputs.has(row.id)) continue
@@ -1106,56 +1099,6 @@ export function createManifest(db) {
1106
1099
  return edges
1107
1100
  },
1108
1101
 
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
-
1159
1102
  // Walk the output folder against recorded snapshots, returning
1160
1103
  // a diff describing missing / mismatched / orphaned /
1161
1104
  // unverifiable. Backs `mikser --audit-output`. Pure: no mutations.
@@ -1543,5 +1486,5 @@ onFinalize(async () => {
1543
1486
  // Dropped here rather than at the start of the next cycle because in
1544
1487
  // watch mode there may not be one for hours, and a stale set held that
1545
1488
  // long would keep re-dispatching entities it saw as missing.
1546
- m.forgetMissingOutputs()
1489
+ forgetMissingOutputs()
1547
1490
  })
@@ -6,9 +6,10 @@ import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
6
6
  import { existsSync } from 'node:fs'
7
7
  import { createRequire } from 'node:module'
8
8
  import { globby } from 'globby'
9
+ import { isFullCycle, outputMissing } from '../invalidation.js'
9
10
  import _ from 'lodash'
10
11
  import map from 'p-map'
11
- import { isFullCycle } from '../utils.js'
12
+
12
13
 
13
14
  // Normalize a `options.presets[name]` value to a consistent
14
15
  // { matches, options } shape so callers don't have to inspect which form
@@ -357,8 +358,10 @@ export function assets(options = {}) {
357
358
  // the only thing that ever said a file was missing.
358
359
  //
359
360
  // 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
361
+ // no amount of marker archaeology changes the answer. Asked of
362
+ // invalidation.js so a derivative and a rendered page agree on what
363
+ // "the output is gone" means.
364
+ if (outputMissing(entity.destination)) return false
362
365
  let result = false
363
366
  let revisions = []
364
367
  const assetChecksum = `${entity.destination}.${entity.preset.checksum}.md5`
@@ -207,12 +207,9 @@ export function files(options = {}) {
207
207
  // so the next one cannot be added to that place and missed here.
208
208
  //
209
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.
210
+ // reads a map instead of doing per-file SQL. What overrides the
211
+ // checksum, the gate asks invalidation.js for itself.
214
212
  const priorChecksums = checksumsByCollection(collection)
215
- const missingOutputs = runtime.manifest?.missingOutputIds() ?? new Set()
216
213
  const scanned = new Set()
217
214
  await pMap(paths, async relativePath => {
218
215
  const { source } = await ensureLink(relativePath)
@@ -230,7 +227,7 @@ export function files(options = {}) {
230
227
  // the catalog already has this file unchanged and there is
231
228
  // nothing to emit. Progress ticks either way — a gated file
232
229
  // was still looked at.
233
- const newChecksum = await gateChecksum(source, id, { priorChecksums, missingOutputs })
230
+ const newChecksum = await gateChecksum(source, id, { priorChecksums })
234
231
  updateProgress()
235
232
  if (newChecksum === null) return
236
233
  await createEntity({
package/src/source.js CHANGED
@@ -46,6 +46,7 @@ import { ACTION } from './constants.js'
46
46
  import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
47
47
  import { reportGated, reportChanged } from './report.js'
48
48
  import { findById, findEntities, checksumsByCollection } from './catalog.js'
49
+ import { bypassReason } from './invalidation.js'
49
50
  import { useDatabase } from './database/index.js'
50
51
 
51
52
  // Per-source-scan registerFile concurrency. The work is dominated by
@@ -81,20 +82,12 @@ const SCAN_CONCURRENCY = 16
81
82
  // disagree, and the losing combination (empty content + a checksum correct
82
83
  // for the finished file) is permanent, because every later sync then
83
84
  // short-circuits on "unchanged".
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
+ export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
92
86
  const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
93
- const canGate = !reload
94
- && !runtime.options.force
95
- && !runtime.catalog?.cacheInvalidated
96
- && !missingOutputs?.has(id)
97
- if (canGate) {
87
+ // What overrides the checksum is not this function's to decide — see
88
+ // invalidation.js. It used to be, and the copy in files.js then missed
89
+ // the output-existence clause when that was added here.
90
+ if (!bypassReason({ reload, id })) {
98
91
  const priorChecksum = priorChecksums
99
92
  ? priorChecksums.get(id)
100
93
  : findById(id)?.checksum
@@ -430,12 +423,6 @@ export function useSource(core, options) {
430
423
  // per-file instead of doing per-file SQL lookups. ~14× faster
431
424
  // at 14k entities (column projection vs full-entity JSON.parse).
432
425
  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()
439
426
  // Parallel register — file read + checksum is I/O-bound. Set
440
427
  // additions, scanStats increments, and journal addEntry calls
441
428
  // are all single-threaded-JS-atomic so no locking required;
@@ -443,7 +430,7 @@ export function useSource(core, options) {
443
430
  // and INSERTs are ~10μs vs ~ms-per-file-read, so the journal
444
431
  // is never the bottleneck.
445
432
  await pMap(files, async (file) => {
446
- await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums, missingOutputs })
433
+ await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums })
447
434
  if (phase === 'import') updateProgress()
448
435
  }, { concurrency: SCAN_CONCURRENCY })
449
436
 
@@ -464,7 +451,7 @@ export function useSource(core, options) {
464
451
  logger.info(scanSummary({ cap, loaded: files.length, ...scanStats }))
465
452
  })
466
453
 
467
- async function registerFile(file, { logger, action = ACTION.CREATE, scanned, priorChecksums, missingOutputs } = {}) {
454
+ async function registerFile(file, { logger, action = ACTION.CREATE, scanned, priorChecksums } = {}) {
468
455
  // stats — when called from the scanHook the outer scan provides
469
456
  // a Map to accumulate counts. For onSync (chokidar single-file
470
457
  // events) the counter is absent and per-file tally is not
@@ -497,7 +484,7 @@ export function useSource(core, options) {
497
484
  }
498
485
  }
499
486
 
500
- const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes, missingOutputs })
487
+ const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
501
488
  if (chksum === null) {
502
489
  if (stats) stats.skipped++
503
490
  // Never becomes a render task, so it would otherwise be invisible
package/src/utils.js CHANGED
@@ -1187,29 +1187,6 @@ export async function writeOutput(file, bytes) {
1187
1187
  return true
1188
1188
  }
1189
1189
 
1190
- // Did this cycle evaluate the whole corpus, or only what changed?
1191
- //
1192
- // The distinction is what makes a "declared X matched nothing" warning
1193
- // worth printing. On an incremental cycle only changed entities are
1194
- // re-evaluated, so a pattern or preset legitimately matches nothing in a
1195
- // run of two — printing it there means the warning fires on every healthy
1196
- // build, and a warning that always fires is one people filter out, taking
1197
- // the real instance with it.
1198
- //
1199
- // True for: --force, a first-run or wiped database, and a cache
1200
- // invalidation. Those are exactly the cases where the import gate is
1201
- // bypassed and every entity is presented for evaluation.
1202
- // Takes the runtime rather than reading the singleton, so a plugin passes
1203
- // the one it was injected with — which is the singleton in a real build and
1204
- // the harness's stand-in under test.
1205
- export function isFullCycle(rt = runtime) {
1206
- return !!(
1207
- rt?.options?.force
1208
- || rt?.options?.firstRun
1209
- || rt?.catalog?.cacheInvalidated
1210
- )
1211
- }
1212
-
1213
1190
  // ── operating-system and file-manager litter ────────────────────────────
1214
1191
  //
1215
1192
  // Exposing a source folder over a network filesystem (mikser-io-drive) or