mikser-io 9.21.0 → 9.22.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.
@@ -59,20 +59,39 @@ snapshot: if you have edited a file and not yet built, the verdict is
59
59
  `source differs from the catalog` — the edit has not been imported yet, so
60
60
  there is nothing to attribute. Build, then ask.
61
61
 
62
- Each `refClosure` edge shows the name that was asked for and the entity
63
- it bound to, and flags the ones that bound to nothing:
62
+ Each `refClosure` edge shows what was asked for and what answers to it,
63
+ and flags the ones nothing answers to:
64
64
 
65
65
  ```
66
- refClosure 4 edges
66
+ refClosure 6 edges
67
67
  ref /hero.txt → /files/hero.txt 24dc5b87
68
68
  ref /does-not-exist [UNRESOLVED — nothing answers to this name]
69
69
  layout /layouts/page.hbs f274678c
70
70
  lookup /contacts [UNRESOLVED — nothing answers to this name]
71
+ query {"meta.href":"/system/navigation"} → /documents/navigation.yml
72
+ query {"meta.href":"/cosmetics/celestetic"} [MATCHES NOTHING]
71
73
  ```
72
74
 
73
- An `[UNRESOLVED]` edge is usually the answer on its own. Add `--json` for
74
- the same report as a machine-readable object. Exits `3` when the entity
75
- cannot be found.
75
+ A dangling edge is usually the answer on its own, and `query` edges are
76
+ where most of it hides on a site whose references go through a sidecar's
77
+ `findEntity` — there, `ref` and `lookup` may be zero and `query`
78
+ everything.
79
+
80
+ Query counts are computed when you ask, not when the edge was recorded.
81
+ That is deliberate: an edge stores the filter WITHOUT its results, so an
82
+ entity appearing tomorrow still invalidates the page that links to it
83
+ today. An edge that recorded its bindings would record nothing for a query
84
+ matching nothing, and creating the target later would re-render nothing.
85
+
86
+ In `--json`, every query edge carries `matched` explicitly — an integer,
87
+ or `null` for a predicate that could not be serialized (`findEntities()`
88
+ with no argument, or a function filter), which invalidates on any mutation
89
+ by design. `matched: 1` also carries `sample` with the id. The field is
90
+ always present, so a consumer never has to read absence as meaning
91
+ anything.
92
+
93
+ Add `--json` for the whole report as a machine-readable object. Exits `3`
94
+ when the entity cannot be found.
76
95
 
77
96
  ### `--json`
78
97
 
package/index.js CHANGED
@@ -50,11 +50,11 @@ export { renderHbs } from './src/plugins/render/hbs.js'
50
50
  export { renderPreset } from './src/plugins/render/preset.js'
51
51
 
52
52
  // Template helpers — these only install functions on `runtime` for templates
53
- // to call. They render nothing, which is what the old render* names hid: two
54
- // of the six factories in this folder are renderers and four are not, and
55
- // naming all six after the object they concern rather than the job they do
56
- // led someone to add renderPreset() expecting a helper and watch every page
57
- // render throw. Renamed in 10.0.0; there are no aliases.
53
+ // to call. They render nothing, and the names say so: two of the six
54
+ // factories in this folder are renderers and four are not, so each is named
55
+ // after the job it does rather than the object it concerns. Naming them all
56
+ // `render*` invites adding renderPreset() expecting a helper and watching
57
+ // every page render throw. There are no aliases for the older names.
58
58
  export { assetUrlHelper } from './src/plugins/render/asset.js'
59
59
  export { hrefUrlHelpers } from './src/plugins/render/href.js'
60
60
  export { resourceUrlHelper } from './src/plugins/render/resource.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.21.0",
3
+ "version": "9.22.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/catalog.js CHANGED
@@ -360,8 +360,10 @@ onFinalize(async () => {
360
360
  }
361
361
  })
362
362
 
363
- // Snapshot the catalog as `{ version, entities: [...] }`. Same shape
364
- // the NDJSON test harness used to read off disk. O(N) — debug only.
363
+ // Snapshot the catalog as `{ version, entities: [...] }` — the schema
364
+ // version plus every entity body. O(N) in both time and memory, so it is
365
+ // for debugging and inspection, never a hot path; `iterateEntities`
366
+ // streams when the result set might be corpus-scale.
365
367
  function exportCatalog() {
366
368
  if (!db?.isOpen) return { version: null, entities: [] }
367
369
  const rows = stmtAllData.all()
package/src/config.js CHANGED
@@ -56,11 +56,9 @@ onLoad(async () => {
56
56
  }
57
57
  }
58
58
 
59
- // v8 used to walk `runtime.config.plugins` looking for matching
60
- // `config/<plugin>.config.js` files to merge into `runtime.config`,
61
- // because plugin entries were strings (names). v9 entries are factory
62
- // call results (closures or descriptors), and plugin options arrive
63
- // as factory args — see ADR-0010 — so per-plugin auxiliary config
64
- // files have nothing to bind to. The loader was removed when the
65
- // plugins list stopped carrying names.
59
+ // Nothing else is loaded. There is deliberately no `config/<plugin>
60
+ // .config.js` channel: plugin options arrive as factory arguments
61
+ // (ADR-0010), and an entry in `plugins` is a factory call result — a
62
+ // closure or a descriptor — carrying no name to bind an auxiliary file
63
+ // to. A plugin wanting file-based config reads it itself.
66
64
  })
package/src/explain.js CHANGED
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // Follows --verify's shape: report and exit, no build phases run.
11
11
  import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
12
- import { findEntity } from './catalog.js'
12
+ import { filterKey } from './track.js'
13
+ import { findEntity, findEntities } from './catalog.js'
13
14
  import runtime from './runtime.js'
14
15
 
15
16
  const shortHash = (h) => (h ? String(h).slice(0, 8) : null)
@@ -28,6 +29,62 @@ async function resolve(reference) {
28
29
  return like ?? null
29
30
  }
30
31
 
32
+ // How many entities each recorded query filter matches RIGHT NOW.
33
+ //
34
+ // A query edge is a predicate — "any entity matching this" — and the
35
+ // recording layer deliberately stores the filter without its results, so
36
+ // that an entity appearing later still invalidates the render. That is the
37
+ // property that makes aggregate pages work and it must not change.
38
+ //
39
+ // Explaining is a different situation: read-only, reporting instead of
40
+ // building, with the catalog already open. So the count is computed here,
41
+ // at read time, where it costs nothing and answers "is this reference
42
+ // dangling?" for the edge kind that carries most of a real site's
43
+ // references.
44
+ //
45
+ // Run through findEntities rather than hand-rolled SQL: stored filters
46
+ // include $regex and anything else sift accepts, and one code path is the
47
+ // only way the count means the same thing the render meant.
48
+ //
49
+ // A null filter is the sentinel for a predicate that could not be
50
+ // serialized (a function filter, or findEntities() with no argument). It
51
+ // invalidates on any mutation by design, and there is nothing to evaluate,
52
+ // so it reports null rather than a misleading zero.
53
+ async function countQueryMatches(snapshots) {
54
+ const counts = new Map()
55
+ for (const snap of snapshots) {
56
+ for (const entry of snap.refClosure ?? []) {
57
+ if (entry.kind !== 'query') continue
58
+ const key = filterKey(entry.filter ?? null)
59
+ if (counts.has(key)) continue
60
+ if (entry.filter == null) {
61
+ counts.set(key, null)
62
+ continue
63
+ }
64
+ try {
65
+ // recordQuery no-ops outside a render's queryContext, so
66
+ // explaining a page cannot record edges onto it.
67
+ const matches = await findEntities(entry.filter)
68
+ counts.set(key, { count: matches.length, sample: matches[0]?.id ?? null })
69
+ } catch {
70
+ // A stored filter the catalog can no longer evaluate is worth
71
+ // saying so about, not worth failing the whole report for.
72
+ counts.set(key, 'unevaluable')
73
+ }
74
+ }
75
+ }
76
+ return counts
77
+ }
78
+
79
+ // Project a counted filter into the report's shape.
80
+ function queryCount(counts, filter) {
81
+ const hit = counts.get(filterKey(filter ?? null))
82
+ if (hit === null) return { matched: null }
83
+ if (hit === 'unevaluable') return { matched: null, unevaluable: true }
84
+ if (!hit) return { matched: null }
85
+ return { matched: hit.count, ...(hit.count === 1 && hit.sample ? { sample: hit.sample } : {}) }
86
+ }
87
+
31
88
  // The verdict line names what moved when it can. That line is the one
32
89
  // people read, so "the input hash differs" there is the answer stopping one
33
90
  // step short of useful.
@@ -60,6 +117,7 @@ export async function explain(reference) {
60
117
  const snapshots = runtime.manifest?.snapshotsFor(entity.id) ?? []
61
118
  const currentHash = inputHashOf(entity)
62
119
  const currentParts = inputPartsOf(entity)
120
+ const queryMatches = await countQueryMatches(snapshots)
63
121
 
64
122
  // The catalog is as of the LAST BUILD. If the file has been edited since,
65
123
  // nothing here knows it yet — the hashes would all agree and the verdict
@@ -141,7 +199,17 @@ export async function explain(reference) {
141
199
  parent: snap.parent ?? null,
142
200
  refClosure: (snap.refClosure ?? []).map(entry =>
143
201
  entry.kind === 'query'
144
- ? { kind: 'query', filter: entry.filter }
202
+ ? {
203
+ kind: 'query',
204
+ filter: entry.filter,
205
+ // An explicit integer, because a MISSING key is
206
+ // ambiguous to a consumer: an audit script reading
207
+ // absence as "unresolved" reports every query edge
208
+ // as dangling. `null` means the filter could not be
209
+ // evaluated (unserializable predicate); a number is
210
+ // a number.
211
+ ...queryCount(queryMatches, entry.filter),
212
+ }
145
213
  : {
146
214
  kind: entry.kind,
147
215
  target: entry.target,
@@ -222,7 +290,16 @@ export function formatExplain(report) {
222
290
  row('refClosure', `${closure.length} edge${closure.length === 1 ? '' : 's'}`)
223
291
  for (const e of closure) {
224
292
  if (e.kind === 'query') {
225
- out.push(` query ${JSON.stringify(e.filter)}`)
293
+ // The count is the point: a query matching nothing is the
294
+ // dangling-reference case for the edge kind that carries most
295
+ // of a real site's references, and it printed identically to
296
+ // one that matched.
297
+ const state = e.unevaluable ? ' [FILTER CANNOT BE EVALUATED]'
298
+ : e.matched === null ? ' (untracked predicate — any mutation invalidates)'
299
+ : e.matched === 0 ? ' [MATCHES NOTHING]'
300
+ : e.matched === 1 ? ` → ${e.sample ?? '1 entity'}`
301
+ : ` → ${e.matched} entities`
302
+ out.push(` query ${JSON.stringify(e.filter)}${state}`)
226
303
  continue
227
304
  }
228
305
  // Show the name asked for and the entity it bound to, since
package/src/manifest.js CHANGED
@@ -225,10 +225,11 @@ function snapToRow(snap) {
225
225
  // is the dominant shape. A page destination treated as a filesystem path
226
226
  // would otherwise be looked for at the root of the disk.
227
227
  //
228
- // Shared, because the two callers needing it drifted into two separate
229
- // bugs: verify() reported every asset missing while printing its real
230
- // path, and hashOutputFile silently recorded no outputHash for one, which
231
- // left 78% of a real project's snapshots presence-checked only.
228
+ // One definition for both callers. verify() and hashOutputFile ask the same
229
+ // question, and a private copy of the join in each is how one mistake
230
+ // becomes two symptoms — one loud (every asset reported missing, at its
231
+ // real and present path) and one silent (no outputHash recorded, leaving
232
+ // most snapshots presence-checked only).
232
233
  export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
233
234
  if (!destination) return undefined
234
235
  const joined = path.join(outputFolder ?? '', destination)
@@ -607,8 +607,9 @@ export function api(options = {}) {
607
607
  // credential declaring `api:delete` still cannot delete on an
608
608
  // endpoint whose `operations` omits it, and a bare token (which
609
609
  // declares nothing, capabilities === null) is bounded by the
610
- // endpoint alone — which is exactly how every endpoint behaved
611
- // before this existed.
610
+ // endpoint alone, which is what makes a plain token still a
611
+ // complete answer: it grants the endpoint's own ceiling and
612
+ // nothing more.
612
613
  const allow = (op) => (req, res, next) => {
613
614
  if (!allowedOps.has(op)) {
614
615
  return res.status(403).json({
@@ -15,8 +15,8 @@ import path from 'node:path'
15
15
  // assets() runs presets and produces derivatives (the work)
16
16
  // renderPreset() renders a preset-authored layout (this file)
17
17
  //
18
- // The helpers were renamed for their role in 10.0.0 for exactly this reason;
19
- // renderPreset keeps its name because it really is a renderer.
18
+ // The URL helpers carry `Helper` in their names for exactly this reason;
19
+ // renderPreset keeps its bare name because it really is a renderer.
20
20
  export async function load({ entity, runtime }) {
21
21
  if (!entity?.preset?.uri) return
22
22
  const preset = await import(`${entity.preset.uri}?stamp=${Date.now()}`)
@@ -253,10 +253,10 @@ export default async ({ entity, options, config, context, state, logger, port })
253
253
  }
254
254
 
255
255
  // Cleanup the renderer's origin (first stage's input) when it's
256
- // a different path from the chain's final destination. The
257
- // single-stage onComplete in layouts.js used to do this; under
258
- // the chain contract the dispatcher owns it so onComplete can
259
- // stay disk-write-agnostic for postprocess outputs.
256
+ // a different path from the chain's final destination. The chain
257
+ // contract puts this on the dispatcher rather than on layouts'
258
+ // onComplete, which is what lets onComplete stay agnostic about
259
+ // whether a postprocess output was written to disk at all.
260
260
  if (entity.origin && entity.origin !== entity.destination) {
261
261
  try { await unlink(path.join(options.outputFolder, entity.origin)) } catch {}
262
262
  }
@@ -264,7 +264,7 @@ export default async ({ entity, options, config, context, state, logger, port })
264
264
  // Return undefined so the engine's onPostprocess loop doesn't
265
265
  // try to thread `result` back through layouts' onComplete (which
266
266
  // would attempt a writeFile against a non-bytes value). The
267
- // postprocess plugins wrote directly to disk; there's nothing
268
- // for the engine to flush.
267
+ // postprocess plugins have written directly to disk by this point;
268
+ // there is nothing for the engine to flush.
269
269
  return undefined
270
270
  }