mikser-io 9.22.0 → 9.23.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.
@@ -115,8 +115,34 @@ Four buckets, and the distinction between them is the point:
115
115
  `never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
116
116
  `cache-disabled`, `postprocessor`, `force`, `no-manifest`.
117
117
 
118
- `inputs-changed` carries a `changed` array naming **which** input moved,
119
- so you do not have to go to the database to find out:
118
+ Each reason carries the detail behind it, so the answer does not require a
119
+ database query. The key is per-reason `changed`, `matched`,
120
+ `dependency` — because each means something specific:
121
+
122
+ | reason | detail | says |
123
+ | --- | --- | --- |
124
+ | `inputs-changed` | `changed: ["meta.title"]` | which of the entity's own inputs moved |
125
+ | `query-matched` | `matched: { filter, by }` | which query fired, and which mutated entity tripped it |
126
+ | `ref-changed` | `dependency: { kind, target, key, cause }` | which dependency, and whether it `changed`, was `deleted`, or was `unhashed` (nothing resolved when the edge was recorded) |
127
+
128
+ `query-matched` is the one worth reading. It fires on pages you were not
129
+ thinking about — a listing whose filter happens to cover the document you
130
+ just edited — and on a page with a dozen query edges, *which* one fired is
131
+ the whole question:
132
+
133
+ ```json
134
+ { "destination": "/index.html", "reason": "query-matched",
135
+ "matched": { "filter": { "id": { "$regex": "^/documents/devices/" } },
136
+ "by": "/documents/devices/hera.md" } }
137
+ ```
138
+
139
+ A `matched.filter` of `null` is a different statement: the page's predicate
140
+ could not be serialized (`findEntities()` with no argument, or a function
141
+ filter), so it re-renders on **any** mutation. That is not a query that
142
+ matched — it is a page with no filter, and narrowing it is the fix the
143
+ catalog already warns about at record time.
144
+
145
+ `inputs-changed` carries a `changed` array naming **which** input moved:
120
146
 
121
147
  ```json
122
148
  { "id": "/files/hero.jpg", "reason": "inputs-changed", "changed": ["checksum"] }
@@ -132,9 +158,14 @@ reads as `meta.weight (added)` / `(removed)`, which is the answer when a
132
158
  document gains or loses front-matter.
133
159
 
134
160
  The array is absent on a first render — there is no prior snapshot to
135
- compare against and on `ref-changed`, because that is a dependency
136
- moving rather than the entity's own inputs. Conflating the two would make
137
- the attribution misleading.
161
+ compare against. It never appears alongside `matched` or `dependency`
162
+ either: a consumer switches on `reason` and reads one field, so a stray
163
+ key from another branch would make that switch wrong.
164
+
165
+ The same detail appears at `--debug` for a watch run, one line per render.
166
+ It is deliberately not in the build's normal output — the counts are the
167
+ summary and `--json` is the record — but when you are watching one page
168
+ misbehave, the trigger is the point.
138
169
 
139
170
  `unchanged` is the interesting one. It means invalidation was coarser
140
171
  than it needed to be — the render was scheduled, ran, and produced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.22.0",
3
+ "version": "9.23.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/engine.js CHANGED
@@ -374,7 +374,20 @@ export async function setup(options) {
374
374
  logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
375
375
  return
376
376
  }
377
- reportRendered(entity, decision.reason, decision.changed)
377
+ reportRendered(entity, decision.reason, decision)
378
+ // Same detail at debug, for tailing a watch run. One line per
379
+ // render is too much for a build's normal output — the counts
380
+ // are the summary and --json is the record — but when you are
381
+ // watching one page misbehave, the trigger is the whole point.
382
+ if (logger.isLevelEnabled?.('debug') ?? true) {
383
+ logger.debug('Render %s: %s%s', entity.id, decision.reason,
384
+ decision.changed?.length ? ` (${decision.changed.join(', ')})`
385
+ : decision.matched ? ` (${JSON.stringify(decision.matched.filter)}`
386
+ + `${decision.matched.by ? ` ← ${decision.matched.by}` : ''})`
387
+ : decision.dependency ? ` (${decision.dependency.kind} `
388
+ + `${decision.dependency.target} ${decision.dependency.cause})`
389
+ : '')
390
+ }
378
391
  // Project reference-marker keys (`$author`, `$hero`, …)
379
392
  // into their normalized form (`author`, `hero`) before
380
393
  // the entity crosses into the renderer — applies whether
package/src/manifest.js CHANGED
@@ -389,11 +389,36 @@ export function createManifest(db) {
389
389
  const sourceLang = entity?.meta?.lang ?? null
390
390
  for (const entry of snapshot.refClosure) {
391
391
  if (entry.kind === 'query') {
392
- if (!entry.filter) return { skip: false, reason: 'query-matched' }
392
+ // A null filter is the sentinel for a predicate that
393
+ // could not be serialized — findEntities() with no
394
+ // argument, or a function filter. It re-renders on ANY
395
+ // mutation, which is a materially different situation
396
+ // from "this specific query matched" and is reported as
397
+ // such: an aggregate page that always re-renders is a
398
+ // filter worth narrowing, which is what catalog.js
399
+ // already warns about at record time.
400
+ if (!entry.filter) {
401
+ return {
402
+ skip: false,
403
+ reason: 'query-matched',
404
+ matched: { filter: null, by: null },
405
+ }
406
+ }
393
407
  if (!mutatedEntities?.size) continue
394
408
  const matcher = sift(entry.filter)
395
409
  for (const mutated of mutatedEntities.values()) {
396
- if (matcher(mutated)) return { skip: false, reason: 'query-matched' }
410
+ // Both the filter and the entity that tripped it are
411
+ // live here. Reporting only "a query matched" throws
412
+ // away the two facts the reader needs — on a page with
413
+ // eighteen query edges, which one fired and what set
414
+ // it off is the entire question.
415
+ if (matcher(mutated)) {
416
+ return {
417
+ skip: false,
418
+ reason: 'query-matched',
419
+ matched: { filter: entry.filter, by: mutated.id ?? null },
420
+ }
421
+ }
397
422
  }
398
423
  continue
399
424
  }
@@ -433,11 +458,26 @@ export function createManifest(db) {
433
458
  continue
434
459
  }
435
460
  }
436
- if (!entry.hash) return { skip: false, reason: 'ref-changed' }
461
+ // Which dependency, and why. Three distinct causes reach
462
+ // the same reason, and the difference is what the reader
463
+ // is after: a layout whose bytes moved, a $-ref whose
464
+ // target was deleted, and an edge recorded with no hash
465
+ // are three different things to go and look at.
466
+ //
467
+ // unhashed nothing resolved when the edge was recorded,
468
+ // so any mutation of that name re-renders
469
+ // deleted the target is gone from the catalog
470
+ // changed the target's own input hash moved
471
+ const dependency = (cause) => ({
472
+ skip: false,
473
+ reason: 'ref-changed',
474
+ dependency: { kind: entry.kind, target: entry.target, key, cause },
475
+ })
476
+ if (!entry.hash) return dependency('unhashed')
437
477
  const currentHash = currentHashes?.get(key)
438
478
  if (currentHash === undefined) continue
439
- if (currentHash === null) return { skip: false, reason: 'ref-changed' }
440
- if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
479
+ if (currentHash === null) return dependency('deleted')
480
+ if (currentHash !== entry.hash) return dependency('changed')
441
481
  }
442
482
  }
443
483
  return { skip: true, reason: 'unchanged' }
package/src/report.js CHANGED
@@ -28,15 +28,25 @@ export function reportGated(count = 1) {
28
28
  store().gated += count
29
29
  }
30
30
 
31
- export function reportRendered(entity, reason, changed) {
31
+ // `decision` is the skipDecision that led here. Its detail travels with the
32
+ // reason, because a reason on its own answers a question nobody asked: WHICH
33
+ // input moved, WHICH query matched and what tripped it, WHICH dependency
34
+ // changed and how. All of it is in hand where the decision is made, and a
35
+ // consumer that has to go to the database for it is one the report failed.
36
+ //
37
+ // Detail keys are per-reason rather than one generic field — `changed`,
38
+ // `matched`, `dependency` each mean something specific, and a single
39
+ // polymorphic key would push the type switch onto every consumer.
40
+ export function reportRendered(entity, reason, decision = {}) {
32
41
  if (!runtime.options?.json) return
33
42
  store().rendered.push({
34
43
  id: entity?.id,
35
44
  destination: entity?.destination ?? null,
36
45
  reason,
37
- // Which input moved, when the reason is inputs-changed. Omitted
38
- // rather than empty so a consumer can test for its presence.
39
- ...(changed?.length ? { changed } : {}),
46
+ // Omitted rather than empty, so presence is meaningful.
47
+ ...(decision.changed?.length ? { changed: decision.changed } : {}),
48
+ ...(decision.matched ? { matched: decision.matched } : {}),
49
+ ...(decision.dependency ? { dependency: decision.dependency } : {}),
40
50
  })
41
51
  }
42
52