mikser-io 9.16.0 → 9.19.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/CLAUDE.md CHANGED
@@ -94,16 +94,39 @@ brevity.
94
94
  corpus-scale and the caller doesn't need an array), `queryEntities`,
95
95
  `readEntity`, `subscribe`, `assertExpand`. Expand internals
96
96
  (`expandLimits`, `expandAndProject`, `findRef`) are PRIVATE.
97
+ All three readers accept either a sift object or a **function**
98
+ predicate; a function cannot be pushed into SQL, so it forces a full
99
+ scan and a JSON.parse per row — prefer an object, and `refFilter(ref)`
100
+ for the "resolve a ref string" case. `refFilter` / `matchesRef` /
101
+ `lookupKeys` are one relation in three directions (query, predicate,
102
+ reverse) and must be changed together: `meta.url` once lived in only
103
+ the first, which made every `$`-ref to a served path silently
104
+ non-invalidating. `test/unit/utils.test.js` property-tests the
105
+ symmetry.
97
106
  - `subscriptions.js` — `subscribe()` primitive. Two modes: journal-
98
107
  walk dispatch (default) and graph dispatch via
99
108
  `runtime.refs.subscribeGraph` (when `expand` is set).
100
109
  - `refs.js` — inverse-reference graph (`$`-keyed refs per ADR-0007).
101
- Persisted as `mikser_refs` rows with FK to `mikser_entities`
102
- (`ON DELETE CASCADE`). Indexed on `target` and `source`. Exposed
103
- at `runtime.refs.*`: `inboundFor`, `outboundFor`, `allRefs`,
104
- `size`, `rename`, `subscribeGraph`, `inverseClosureOf`. Plus
105
- `refExists` module-level. Prepared statements through the shared
106
- sqlite handle.
110
+ Persisted as `mikser_refs` rows with FK on `source_id` to
111
+ `mikser_entities` (`ON DELETE CASCADE`); indexed on `target_ref`
112
+ and `target_id`. Each row carries BOTH the string asked for
113
+ (`target_ref` — an id, `meta.href`, `meta.url`, or id-minus-
114
+ extension) and the entity it resolved to (`target_id`, `''` when
115
+ nothing did). Both are load-bearing: a name alone cannot survive the
116
+ target renaming itself, and an id alone cannot express a forward or
117
+ dangling reference. `inverseClosureOf` takes the union of a name
118
+ query and an identity query, so it is a superset of name-only
119
+ matching. Kinds: `ref` (static `$`-ref, owned by `indexEntity`),
120
+ and `layout` / `partial` / `query` / `lookup` (render-time, owned by
121
+ `replaceDynamic`) — the clears divide on `kind = 'ref'` vs
122
+ `kind != 'ref'`, so a render-time edge must never use `ref`.
123
+ Exposed at `runtime.refs.*`: `inboundFor`, `outboundFor`,
124
+ `dynamicInboundFor`, `dynamicOutboundFor`, `allRefs`, `size`,
125
+ `rename`, `inverseClosureOf`, `resolveRefIds`, `replaceDynamic`,
126
+ `clearDynamic`, `subscribeGraph`, `subscribeQuery`. Plus `refExists`
127
+ module-level. `REFS_SCHEMA` is exported so tests build against the
128
+ real schema instead of a copy. Prepared statements through the
129
+ shared sqlite handle.
107
130
  - `engine.js` — `setup()`, lifecycle wiring, render + postprocess
108
131
  dispatchers, manifest tracking. Owns the Piscina worker pools
109
132
  (`renderWorkers`, `postprocessWorkers`); both are lazy
@@ -191,6 +214,12 @@ brevity.
191
214
  - `constants.js` — `OPERATION` (CREATE/UPDATE/DELETE/RENDER/
192
215
  POSTPROCESS), `ACTION` (sync action types), `TASKS` (`INLINE`/
193
216
  `SERIAL`/`WORKER` — dispatch modes).
217
+ - `../testing/harness.js` — the in-memory plugin harness, OUTSIDE
218
+ `src/` because it ships in the package (`.npmignore` excludes
219
+ `test/`). Sibling plugins import it as
220
+ `mikser-io/testing/harness.js` rather than copying it; the copies
221
+ drifted before it moved here. `test/unit/plugin-harness.js` is a
222
+ re-export for this repo's own tests.
194
223
 
195
224
  ## Canonical sibling plugins
196
225
 
@@ -516,11 +545,15 @@ There is **no `--mcp` CLI flag**. Activation is plugin-presence only.
516
545
 
517
546
  ## Test suites
518
547
 
519
- - `npm run test:unit` — 363 unit tests across plugins + utilities
520
- - `npm run test:scenarios` — 18 subprocess-spawned end-to-end runs
521
- (manifest skip, refs replay, watch-mode change/delete). Spawns
522
- mikser fresh per scenario so module-level catalog/refs/manifest
523
- state can't leak between tests.
548
+ - `npm run test:unit` — 540 unit tests across plugins + utilities
549
+ - `npm run test:scenarios` — 96 assertions over 20 subprocess-spawned
550
+ end-to-end runs (manifest skip, refs replay, watch-mode
551
+ change/delete, lookup + asset invalidation, unchanged output,
552
+ `--force`, `--explain`, `--json`). Spawns mikser fresh per scenario
553
+ so module-level catalog/refs/manifest state can't leak between
554
+ tests. Scenarios use `freshWorkdir()` in os.tmpdir — never
555
+ `test/fixture`, which is tracked and must stay byte-identical
556
+ (`git status --short test/fixture` after any run that builds).
524
557
  - `npm run test:smoke` — full lifecycle build of `test/fixture/`
525
558
  (with vector + decap + post-mjml + post-pdf if env supports).
526
559
  Exercises both INLINE postprocess (PDF) and WORKER postprocess
@@ -531,8 +564,8 @@ There is **no `--mcp` CLI flag**. Activation is plugin-presence only.
531
564
 
532
565
  ## Dev workspace
533
566
 
534
- The siblings live side-by-side under `/Users/dick/Projects/mikser/`
535
- and share an npm workspace declared at the parent's `package.json`:
567
+ The siblings live side-by-side in one parent folder and share an npm
568
+ workspace declared at that parent's `package.json`:
536
569
 
537
570
  ```json
538
571
  {
@@ -573,6 +606,10 @@ the consumer's own project tree — no duplication, no bug.
573
606
 
574
607
  ## Reference
575
608
 
609
+ - `docs/diagnostics.md` — "why did it do that?" — `--explain`,
610
+ `--json`, `--verify`, the sqlite tables, and every introspection
611
+ surface, indexed by the question it answers. Start here when
612
+ debugging rather than reading engine source.
576
613
  - `docs/architecture.md` — module map (audit before relying
577
614
  on specifics; may have drift)
578
615
  - `docs/decisions/` — ADRs
package/README.md CHANGED
@@ -286,6 +286,7 @@ Mikser itself has a previous chapter: the [legacy 7.x line](https://github.com/a
286
286
  | [Entities](./docs/entities.md) | Users & Developers | Entity model, operations, journal, catalog |
287
287
  | [Rendering](./docs/rendering.md) | Users & Developers | Render pipeline, render plugins, render modes |
288
288
  | [Watch Mode](./docs/watch-mode.md) | Users | File watching, scheduled tasks, incremental builds |
289
+ | [Diagnostics](./docs/diagnostics.md) | Users & Developers | **"Why did it do that?"** — `--explain`, `--json`, `--verify`, the sqlite tables, and every introspection surface, indexed by the question it answers |
289
290
  | [MCP](https://github.com/almero-digital-marketing/mikser-io-mcp#readme) | Users | The `mikser-io-mcp` plugin — tool surface, `mikser://` resources, twelve worked AI-driven scenarios |
290
291
  | [Caching](./docs/caching.md) | Users (production) | The `cache: true` disk cache + working nginx config for reverse-proxy failover |
291
292
  | [Architecture](./docs/architecture.md) | Developers | Module-level reference — what's in each file |
@@ -0,0 +1,289 @@
1
+ # Diagnostics
2
+
3
+ The other documents here are organised by subsystem — the lifecycle, the
4
+ catalog, the render pipeline. That is the shape for learning how mikser
5
+ works. This one is organised by the question you arrive with, which is
6
+ almost always a form of *"why did it do that?"*
7
+
8
+ Every surface below already exists. If answering a question means reading
9
+ engine source, the entry point is missing and belongs on this page.
10
+
11
+ ## Start here
12
+
13
+ | Your question | Reach for |
14
+ | --- | --- |
15
+ | Why didn't this page rebuild? | [`--explain`](#--explain-entity) |
16
+ | What did this build actually change? | [`--json`](#--json) |
17
+ | Does the output folder match what mikser thinks it wrote? | [`--verify`](#--verify) |
18
+ | What happened during the last cycle, in order? | [`mikser_journal`](#mikser_journal) |
19
+ | What depends on this entity? | [`runtime.refs`](#runtimerefs) |
20
+ | Which layout claimed this document, and why that one? | [`layouts.inspect()`](#layoutsinspect) |
21
+ | Why does this page's output look stale? | [`runtime.manifest`](#runtimemanifest) |
22
+ | Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
23
+
24
+ ## Command line
25
+
26
+ ### `--explain <entity>`
27
+
28
+ The single most direct answer to "why didn't this rebuild". Reports
29
+ instead of building. Accepts an id, a `meta.href`, or an id without its
30
+ extension — the same three forms a `$`-ref accepts, so you can paste
31
+ whatever you have.
32
+
33
+ ```bash
34
+ npx mikser --explain /documents/en/posts/hello.md
35
+ ```
36
+
37
+ It prints the entity's layout and **why that layout matched**, its
38
+ destination, its `inputHash`, whether the file on disk still agrees with
39
+ the catalog, every recorded render with its `refClosure`, and a verdict
40
+ in plain words — `would be SKIPPED — input hash unchanged`, or `would
41
+ re-render`, or `source file is gone`.
42
+
43
+ Each `refClosure` edge shows the name that was asked for and the entity
44
+ it bound to, and flags the ones that bound to nothing:
45
+
46
+ ```
47
+ refClosure 4 edges
48
+ ref /hero.txt → /files/hero.txt 24dc5b87
49
+ ref /does-not-exist [UNRESOLVED — nothing answers to this name]
50
+ layout /layouts/page.hbs f274678c
51
+ lookup /contacts [UNRESOLVED — nothing answers to this name]
52
+ ```
53
+
54
+ An `[UNRESOLVED]` edge is usually the answer on its own. Add `--json` for
55
+ the same report as a machine-readable object. Exits `3` when the entity
56
+ cannot be found.
57
+
58
+ ### `--json`
59
+
60
+ A build report on stdout, as JSON. Logs and the banner move to stderr
61
+ under this flag, so stdout parses whole.
62
+
63
+ ```bash
64
+ npx mikser --json | jq '.summary'
65
+ ```
66
+
67
+ Four buckets, and the distinction between them is the point:
68
+
69
+ | Bucket | Meaning |
70
+ | --- | --- |
71
+ | `rendered` | the render ran, with a `reason` per entity |
72
+ | `skipped` | the manifest decided not to render, with a `reason` |
73
+ | `unchanged` | the render ran and produced bytes identical to what was already on disk |
74
+ | `gated` | a count — the source was unchanged, so no render was ever scheduled |
75
+
76
+ `reason` is a stable vocabulary you can assert on: `unchanged`,
77
+ `never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
78
+ `cache-disabled`, `postprocessor`, `force`, `no-manifest`.
79
+
80
+ `unchanged` is the interesting one. It means invalidation was coarser
81
+ than it needed to be — the render was scheduled, ran, and produced
82
+ nothing new. A high count is not a bug, but it tells you where the
83
+ dependency graph is conservative.
84
+
85
+ Warnings carry a stable `code` alongside their prose, so a test can
86
+ assert "this build produced no preset-no-match" without grepping a
87
+ sentence someone may later reword.
88
+
89
+ ### `--verify`
90
+
91
+ Walks the output folder against the recorded snapshots and reports drift
92
+ instead of building. Four categories, and the split matters:
93
+
94
+ | Category | Meaning | Severity |
95
+ | --- | --- | --- |
96
+ | `Missing` | a snapshot records a destination that is not on disk | error |
97
+ | `Mismatched` | the file's bytes differ from the recorded `outputHash` | error |
98
+ | `No hash` | a snapshot with no recorded hash — nothing to compare | warning |
99
+ | `Orphan` | a file on disk that no snapshot claims | warning |
100
+
101
+ Exit codes make it usable as a CI gate directly: `2` if anything is
102
+ missing or mismatched, `1` if only warnings, `0` when clean, and `2` when
103
+ there is no manifest to check against.
104
+
105
+ ```bash
106
+ npx mikser --verify || echo "output folder has drifted"
107
+ ```
108
+
109
+ `Orphan` is the one to read carefully — it is normal for files that
110
+ another plugin writes (assets, files, data), and a real signal for a page
111
+ whose layout stopped producing it.
112
+
113
+ ### The rest, briefly
114
+
115
+ | Flag | Use |
116
+ | --- | --- |
117
+ | `-f, --force` | ignore all three gates (import checksum, dispatch, manifest) and re-render everything |
118
+ | `-R, --resume` | continue from a previous interrupted run's journal; skips the filesystem scan |
119
+ | `-r, --clear` | clear state before running |
120
+ | `-d, --debug` / `-t, --trace` | raise log level; `trace` includes per-entity catalog writes |
121
+
122
+ `--force` composes with the unchanged-output check: it redoes the work
123
+ without touching files whose bytes did not move, so it is cheap to reach
124
+ for and its `unchanged` count tells you how much of the catalog was stale
125
+ by suspicion rather than in fact.
126
+
127
+ ## The database
128
+
129
+ Everything mikser knows lives in one SQLite file at
130
+ `<workingFolder>/runtime/mikser.sqlite`. It is readable while a build
131
+ runs (WAL mode) and it is often faster to ask it directly than to add
132
+ logging.
133
+
134
+ ```bash
135
+ sqlite3 runtime/mikser.sqlite
136
+ ```
137
+
138
+ Five tables:
139
+
140
+ | Table | Holds |
141
+ | --- | --- |
142
+ | `mikser_entities` | the catalog — one row per entity, `data` is the JSON body |
143
+ | `mikser_journal` | the current cycle's operations, in order — cleared per cycle, which is what makes `--resume` possible |
144
+ | `mikser_refs` | the dependency graph, both directions |
145
+ | `mikser_snapshots` | what was rendered, where, and from which inputs |
146
+ | `mikser_meta` | schema version and config checksum, for cache invalidation |
147
+
148
+ ### `mikser_journal`
149
+
150
+ The ordered record of what the engine did this cycle — one row per
151
+ operation, per consumer. When a build "did nothing" and you cannot see
152
+ why, this is where the absence becomes visible: an entity that was never
153
+ imported has no CREATE, and an entity gated at import has no RENDER.
154
+
155
+ ```sql
156
+ SELECT operation, count(*) FROM mikser_journal GROUP BY operation;
157
+ ```
158
+
159
+ ### `mikser_refs`
160
+
161
+ One row per dependency edge, carrying both the question and the answer:
162
+
163
+ | Column | Meaning |
164
+ | --- | --- |
165
+ | `source_id` | the entity that depends |
166
+ | `target_ref` | the string it asked for — an id, a `meta.href`, a `meta.url`, or an id minus its extension |
167
+ | `target_id` | the entity that string resolved to, `''` if nothing did |
168
+ | `kind` | `ref` (static `$`-ref), `layout`, `partial`, `query`, `lookup` |
169
+ | `field` | for `ref`, the dotted path in `meta` |
170
+
171
+ "What breaks if I rename this?" is one query:
172
+
173
+ ```sql
174
+ SELECT source_id, kind, target_ref FROM mikser_refs
175
+ WHERE target_id = '/documents/en/authors/dick.yml';
176
+ ```
177
+
178
+ A row with `target_id = ''` is a dangling reference — the dependent asked
179
+ for a name nothing currently answers to.
180
+
181
+ ### `mikser_snapshots`
182
+
183
+ One row per rendered destination: `inputHash` (what it was rendered
184
+ from), `outputHash` (the bytes it produced), `refClosure` (the
185
+ dependencies it recorded), `renderedAt`. An entity can have several — one
186
+ per matched layout, one per paginated page.
187
+
188
+ ## From inside a plugin or a REPL
189
+
190
+ ### `runtime.refs`
191
+
192
+ The dependency graph, queryable both ways.
193
+
194
+ | Method | Answers |
195
+ | --- | --- |
196
+ | `inboundFor(ref)` | which entities `$`-ref this, and through which field |
197
+ | `outboundFor(id)` | which refs this entity emits |
198
+ | `dynamicInboundFor(target)` | which entities depend on this via layout / partial / query / lookup |
199
+ | `dynamicOutboundFor(id)` | the render-time edges this entity recorded |
200
+ | `inverseClosureOf(seeds)` | everything that transitively depends on these — the set a change dispatches |
201
+ | `resolveRefIds(ref)` | which entity ids a ref string resolves to, by all four forms |
202
+ | `allRefs()` / `size()` | every static ref target; edge and source counts |
203
+ | `subscribeGraph(opts)` | react to changes within N hops of entities matching a filter |
204
+ | `subscribeQuery(opts)` | react to changes matching a query |
205
+
206
+ `inverseClosureOf` is the one to reach for when a change re-rendered more
207
+ than you expected — it returns exactly the set the scheduler will act on.
208
+
209
+ ### `runtime.manifest`
210
+
211
+ What was rendered and whether it needs redoing.
212
+
213
+ | Method | Answers |
214
+ | --- | --- |
215
+ | `snapshotsFor(id)` | every snapshot for an entity, without knowing its destinations |
216
+ | `lookup({id, destination})` | one snapshot |
217
+ | `skipDecision(entity, …)` | `{ skip, reason }` — the same reason `--json` reports |
218
+ | `recordedHashes()` | the dep-hashes dependents last saw |
219
+ | `queryAffected(mutated)` | which query-dependent snapshots this mutation hits |
220
+ | `verify({outputFolder})` | `{ missing, mismatched, unverifiable, orphaned }` — what `--verify` reports; pure, no mutations |
221
+ | `size()` | snapshot count |
222
+
223
+ `snapshotsFor(id)` exists because an entity can render to several
224
+ destinations and a caller asking "what happened to this?" does not know
225
+ them in advance — which is exactly the position you are in when a page
226
+ did not change and you want to know why.
227
+
228
+ ### `layouts.inspect()`
229
+
230
+ Exposed by `mikser-io-layouts` at `runtime.options.layouts.inspect(id)`.
231
+ Answers "what does this layout actually do?" — its template source, the
232
+ partials and references the renderer parses out of it, and the recorded
233
+ `refClosure` of up to `samples` entities that used it, so you can see
234
+ what it depended on in practice rather than in theory.
235
+
236
+ ```js
237
+ const report = await runtime.options.layouts.inspect('/layouts/post.hbs', { samples: 3 })
238
+ ```
239
+
240
+ Throws with `code: 'LAYOUT_NOT_FOUND'` for an id that is not a layout.
241
+
242
+ ### `schemas.names()` / `schemas.lookup()`
243
+
244
+ Exposed by `mikser-io-schemas` at `runtime.options.schemas`. `names()`
245
+ lists every loaded schema; `lookup(name)` returns the zod object.
246
+
247
+ The failure worth knowing about: a schema that loaded but matched no
248
+ entity is reported at finalize, because validation that silently never
249
+ runs looks exactly like validation that passed. If you configured
250
+ schemas and see no errors, check that warning before believing the
251
+ content is clean.
252
+
253
+ ### `preview`
254
+
255
+ `mikser-io`'s preview plugin exposes `runtime.options.preview = { store,
256
+ get, stats, config }` — the on-demand render cache, useful for asking
257
+ what has been rendered outside a build.
258
+
259
+ ## When mikser is silent
260
+
261
+ Silence is this engine's characteristic failure mode: a declaration that
262
+ selected nothing, or an input nothing tracked, and a green build. The
263
+ surfaces that turn silence into a statement:
264
+
265
+ - **A build that rendered nothing** — `--json` distinguishes `gated`
266
+ (source unchanged, never scheduled) from `skipped` (scheduled, manifest
267
+ declined). Those have different causes.
268
+ - **A page pinned to stale bytes** — `--explain` on it, then read the
269
+ `refClosure` for an `[UNRESOLVED]` edge or a dependency you expected to
270
+ be listed and is not. An input nothing recorded is an input nothing
271
+ invalidates.
272
+ - **A pattern or query that matched nothing** — several plugins warn on
273
+ this now (layout patterns, preset matching, null filters). The warnings
274
+ carry stable `code`s in `--json`.
275
+ - **Output that does not match the config** — the config's bytes take
276
+ part in cache invalidation, so a config change forces a rebuild; but a
277
+ module the config *imports* does not. If you changed a helper the config
278
+ pulls in, use `--force`.
279
+ - **A plugin that appears to do nothing** — `No plugins loaded` with a
280
+ config present is a warning naming the file. A config that fails to
281
+ load now exits non-zero rather than loading as empty.
282
+
283
+ ## See also
284
+
285
+ - [Entities](./entities.md) — the entity model, operations, journal, catalog
286
+ - [Lifecycle](./lifecycle.md) — which phase a hook can see what in
287
+ - [API Reference](./api-reference.md) — full signatures
288
+ - [Decisions](./decisions/) — why the graph is shaped this way, especially
289
+ ADR-0002 (files are the source of truth) and ADR-0009 (SQLite substrate)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.16.0",
3
+ "version": "9.19.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
@@ -414,7 +414,10 @@ export function findById(id) {
414
414
  // (hit the LRU and validate remaining clauses inline). For other
415
415
  // filters, translate to SQL + JS fallback.
416
416
  export async function findEntity(query) {
417
- if (!query || typeof query !== 'object') return
417
+ // A function is a valid filter, so the guard has to admit one:
418
+ // `typeof fn !== 'object'`, and rejecting it here returns undefined
419
+ // for every function filter without a word.
420
+ if (!query || (typeof query !== 'object' && typeof query !== 'function')) return
418
421
  recordQuery(query)
419
422
 
420
423
  if (db?.isOpen) {
@@ -429,13 +432,12 @@ export async function findEntity(query) {
429
432
  }
430
433
 
431
434
  const t = siftToSql(query)
432
- const sql = `SELECT data FROM mikser_entities ${t.sql} LIMIT ${t.jsFilter ? '' : '1'}`
433
- // With a jsFilter we may need to scan multiple rows before
434
- // finding a match; without one, LIMIT 1 short-circuits.
435
- const stmt = db.prepare(t.jsFilter
435
+ // With a residual predicate we may need to scan multiple rows
436
+ // before finding a match; without one, LIMIT 1 short-circuits.
437
+ const matcher = residualMatcher(query, t.jsFilter)
438
+ const stmt = db.prepare(matcher
436
439
  ? `SELECT data FROM mikser_entities ${t.sql}`
437
440
  : `SELECT data FROM mikser_entities ${t.sql} LIMIT 1`)
438
- const matcher = t.jsFilter ? sift(t.jsFilter) : null
439
441
  for (const row of stmt.iterate(...t.params)) {
440
442
  const entity = JSON.parse(row.data)
441
443
  if (!matcher || matcher(entity)) return entity
@@ -454,7 +456,7 @@ export async function findEntity(query) {
454
456
  if (Object.keys(rest).length === 0) return entity
455
457
  return sift(rest)(entity) ? entity : undefined
456
458
  }
457
- const m = sift(query)
459
+ const m = typeof query === 'function' ? query : sift(query)
458
460
  for (const entity of shim.all()) {
459
461
  if (m(entity)) return entity
460
462
  }
@@ -463,6 +465,46 @@ export async function findEntity(query) {
463
465
  // All entities (no arg) or those matching `query`. Indexed clauses
464
466
  // push down to SQL; un-indexed clauses run as a sift filter over the
465
467
  // pushdown result set.
468
+ // The residual predicate a SQL translation could not absorb.
469
+ //
470
+ // `translate()` reports `jsFilter: null` for two different situations:
471
+ // nothing is left to check in JS, and the filter could not be read at
472
+ // all. A function filter is the second. Reading that null as the first
473
+ // discards the predicate — `findEntities(fn)` then answers with the
474
+ // entire catalog and `findEntity(fn)` with whichever row LIMIT 1
475
+ // returns, both silently. So the function case is decided HERE, from
476
+ // `query` itself, rather than inferred from what translate returned.
477
+ //
478
+ // The plugin harness applies function filters, so a plugin that relies
479
+ // on this passes its own tests either way; only production diverges.
480
+ //
481
+ // A function cannot be indexed — it forces a full scan and a JSON.parse
482
+ // per row — so prefer an object filter, and `refFilter()` for the common
483
+ // "resolve a ref string" case.
484
+ function residualMatcher(query, jsFilter) {
485
+ if (jsFilter) return sift(jsFilter)
486
+ if (typeof query === 'function') {
487
+ warnFullScan()
488
+ return query
489
+ }
490
+ return null
491
+ }
492
+
493
+ // Once per process: a function filter is a performance cliff, not an
494
+ // error, and a message per call on a hot path costs more than the cliff.
495
+ let warnedFullScan = false
496
+ function warnFullScan() {
497
+ if (warnedFullScan) return
498
+ warnedFullScan = true
499
+ try {
500
+ useLogger().debug(
501
+ 'Catalog query used a function filter: this cannot be indexed and '
502
+ + 'scans the whole catalog. Prefer an object filter, or refFilter(ref) '
503
+ + 'to resolve a ref string.',
504
+ )
505
+ } catch { /* logger may not exist yet */ }
506
+ }
507
+
466
508
  export async function findEntities(query) {
467
509
  recordQuery(query)
468
510
 
@@ -474,17 +516,14 @@ export async function findEntities(query) {
474
516
  const stmt = db.prepare(`SELECT data FROM mikser_entities ${t.sql}`)
475
517
  const rows = stmt.all(...t.params)
476
518
  const entities = rows.map(r => JSON.parse(r.data))
477
- if (t.jsFilter) {
478
- const matcher = sift(t.jsFilter)
479
- return entities.filter(matcher)
480
- }
481
- return entities
519
+ const matcher = residualMatcher(query, t.jsFilter)
520
+ return matcher ? entities.filter(matcher) : entities
482
521
  }
483
522
 
484
523
  const shim = mapStub()
485
524
  if (!shim) return []
486
525
  if (!query) return shim.all()
487
- const m = sift(query)
526
+ const m = typeof query === 'function' ? query : sift(query)
488
527
  return shim.all().filter(m)
489
528
  }
490
529
 
@@ -522,7 +561,7 @@ export async function* iterateEntities(query) {
522
561
  const stmt = db.prepare(
523
562
  `SELECT id, data FROM mikser_entities ${where} ORDER BY id LIMIT ?`,
524
563
  )
525
- const matcher = t.jsFilter ? sift(t.jsFilter) : null
564
+ const matcher = residualMatcher(query, t.jsFilter)
526
565
 
527
566
  let lastId = ''
528
567
  while (true) {
@@ -540,7 +579,7 @@ export async function* iterateEntities(query) {
540
579
  // Stub path — for tests that don't bring up the database.
541
580
  const shim = mapStub()
542
581
  if (!shim) return
543
- const m = query ? sift(query) : null
582
+ const m = query ? (typeof query === 'function' ? query : sift(query)) : null
544
583
  for (const entity of shim.all()) {
545
584
  if (!m || m(entity)) yield entity
546
585
  }
package/src/config.js CHANGED
@@ -3,6 +3,7 @@ import { useLogger } from './engine.js'
3
3
  import { onLoad } from './lifecycle.js'
4
4
  import { checksum } from './utils.js'
5
5
  import path from 'node:path'
6
+ import { existsSync } from 'node:fs'
6
7
 
7
8
  onLoad(async () => {
8
9
  const logger = useLogger()
@@ -29,15 +30,30 @@ onLoad(async () => {
29
30
  runtime.options.configChecksum = null
30
31
  }
31
32
 
32
- try {
33
+ // Absence is decided by looking for the file, NOT by catching
34
+ // ERR_MODULE_NOT_FOUND from the import.
35
+ //
36
+ // Node raises that same code for "the config file is missing" and for
37
+ // "the config file exists and something IT imports is missing" — a
38
+ // mistyped package name, a renamed local module, a dependency that was
39
+ // never installed. Catching the code cannot tell them apart, and a
40
+ // config with one bad import then loads as `{}`: the build reports "No
41
+ // plugins loaded" and exits 0, a green build with an empty output
42
+ // folder, one line below having printed the config's path.
43
+ //
44
+ // Every other config failure is loud — a syntax error and a throw
45
+ // during evaluation both exit 1. Module resolution is the only one that
46
+ // needs this to stay in line with them.
47
+ if (!existsSync(configFile)) {
48
+ logger.debug('No config file at %s — using defaults', configFile)
49
+ } else {
50
+ // No catch: any failure loading a config that EXISTS is fatal.
33
51
  const config = await import(configFile)
34
52
  if (typeof config.default == 'function') {
35
53
  runtime.config = await config.default(runtime)
36
54
  } else if (typeof config.default == 'object') {
37
55
  runtime.config = config.default
38
56
  }
39
- } catch (err) {
40
- if (err.code != 'ERR_MODULE_NOT_FOUND') throw err
41
57
  }
42
58
 
43
59
  // v8 used to walk `runtime.config.plugins` looking for matching
package/src/explain.js CHANGED
@@ -117,10 +117,10 @@ export async function explain(reference) {
117
117
  : {
118
118
  kind: entry.kind,
119
119
  target: entry.target,
120
- // What the name actually resolved to. A binding
121
- // that is absent means the edge is dangling —
122
- // the single most useful thing to know when a
123
- // page will not re-render and nobody can say why.
120
+ // What the name resolved to. An absent binding
121
+ // means a dangling edge — the most useful single
122
+ // fact when a page will not re-render and nothing
123
+ // says why.
124
124
  bound: entry.targetIds?.length ? entry.targetIds
125
125
  : entry.targetId ? [entry.targetId]
126
126
  : [],