mikser-io 9.18.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.18.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
@@ -35,16 +35,15 @@ onLoad(async () => {
35
35
  //
36
36
  // Node raises that same code for "the config file is missing" and for
37
37
  // "the config file exists and something IT imports is missing" — a
38
- // mistyped package name, a renamed local module, a dependency that
39
- // was never installed. Catching the code swallowed both, so a config
40
- // with one bad import loaded as `{}` and the build reported "No
41
- // plugins loaded" and exited 0: a green build with an empty output
42
- // folder, one line away from having printed the config's path.
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
43
  //
44
- // Every other config failure was already loud — a syntax error or a
45
- // throw during evaluation both exit 1. Module resolution was the one
46
- // silent case, so this brings it in line rather than inventing a new
47
- // policy.
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.
48
47
  if (!existsSync(configFile)) {
49
48
  logger.debug('No config file at %s — using defaults', configFile)
50
49
  } else {
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
  : [],
package/src/manifest.js CHANGED
@@ -97,11 +97,11 @@ function buildRefClosure(entity, deps) {
97
97
  const closure = []
98
98
  const seen = new Set()
99
99
  // `targetId`/`targetIds` are the recorded BINDING — which entity the
100
- // ref actually resolved to. They have to survive into the snapshot:
101
- // this projection used to keep only {kind, target, hash}, so the
102
- // binding was written to mikser_refs, dropped here, and skipDecision
103
- // had nothing but the name to compare. The scheduler found the
104
- // dependent and the manifest then skipped it.
100
+ // ref resolved to — and they have to survive into the snapshot. A
101
+ // projection that keeps only {kind, target, hash} leaves the binding
102
+ // in mikser_refs and out of the snapshot, so skipDecision has nothing
103
+ // but the name to compare: the scheduler finds the dependent and the
104
+ // manifest then skips it.
105
105
  function pushTarget(kind, target, hash, targetId, targetIds) {
106
106
  if (!target) return
107
107
  const key = `${kind}:${target}`
@@ -122,11 +122,11 @@ function buildRefClosure(entity, deps) {
122
122
  if (entity.meta) {
123
123
  for (const { ref } of extractRefs(entity.meta)) {
124
124
  // Resolve through the refs index, which mirrors refFilter's
125
- // four forms. findById alone — as this used to do — is an
126
- // exact primary-key read: a $-ref written as a meta.href or
127
- // a served meta.url path (ADR-0011) resolved to nothing, so
128
- // the edge was stored with no hash and no binding, and the
129
- // manifest could not tell the target had changed.
125
+ // four forms. findById alone is an exact primary-key read, so
126
+ // a $-ref written as a meta.href or a served meta.url path
127
+ // (ADR-0011) resolves to nothing and the edge lands with no
128
+ // hash and no binding — leaving the manifest unable to see
129
+ // that the target changed.
130
130
  const ids = runtime.refs?.resolveRefIds?.(ref) ?? (findById(ref) ? [ref] : [])
131
131
  const bound = ids.length === 1 ? findById(ids[0]) : null
132
132
  pushTarget(
@@ -308,17 +308,17 @@ export function createManifest(db) {
308
308
  // cache-disabled meta.cache === false
309
309
  // force --force: skip nothing, ask nothing
310
310
  skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
311
- // --force means "ignore what you think you know". THREE gates can
312
- // stop a render — source.js's import checksum gate, layouts'
313
- // dispatch filter, and this one — and force reached only the
314
- // first two. Since this one runs last, a forced build re-imported
315
- // everything (gated=0), re-dispatched everything, and then
316
- // dropped all of it here with reason `unchanged`: rendered=0, and
317
- // a summary that read like a successful build.
311
+ // --force means "ignore what you think you know". THREE gates
312
+ // can stop a render — source.js's import checksum gate,
313
+ // layouts' dispatch filter, and this one — and force has to
314
+ // reach all three. This one runs last, so if it alone ignores
315
+ // force, a forced build re-imports everything, re-dispatches
316
+ // everything, and drops all of it here as `unchanged`:
317
+ // rendered=0, and a summary that reads like a success.
318
318
  //
319
- // That made --force useless in exactly the situation it exists
320
- // for, which is when the invalidation graph is under suspicion —
321
- // including the advice the preset no-match warning gives.
319
+ // That is the situation --force exists for — the invalidation
320
+ // graph being under suspicion — including where the preset
321
+ // no-match warning tells the operator to use it.
322
322
  if (runtime.options?.force) return { skip: false, reason: 'force' }
323
323
  if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
324
324
  const snapshot = this.lookup(entity)
@@ -344,10 +344,10 @@ export function createManifest(db) {
344
344
  // the target renaming itself.
345
345
  //
346
346
  // Both, not the first match, because the two can disagree:
347
- // the bound entity may have been re-persisted unchanged in
348
- // the same cycle that a DIFFERENT entity started answering
349
- // to the same name. Stopping at the binding would compare
350
- // an unchanged hash and skip, silently ignoring the new
347
+ // the bound entity may be re-persisted unchanged in the
348
+ // same cycle that a DIFFERENT entity starts answering to
349
+ // the same name. Stopping at the binding compares an
350
+ // unchanged hash and skips, silently ignoring the new
351
351
  // claimant. The name key also carries unresolved/forward
352
352
  // edges, which is how a link to a not-yet-existing page
353
353
  // invalidates once that page appears.
@@ -499,20 +499,19 @@ export function createManifest(db) {
499
499
  }
500
500
  if (track?.lookups) {
501
501
  for (const [target, ids] of track.lookups) {
502
- // The lookup helper resolved this already and handed
503
- // over the ids, so the hash comes from the BOUND
504
- // entity. Hashing findById(target) instead — as this
505
- // did — silently produced no hash at all whenever the
506
- // target was an href or url form, because findById is
507
- // an exact primary-key read: it resolves neither
508
- // meta.href nor meta.url nor a stripped extension.
509
- // Every such edge was hashless, so the manifest could
510
- // not tell "target moved" from "target changed".
502
+ // The lookup helper has already resolved this and
503
+ // handed over the ids, so the hash comes from the
504
+ // BOUND entity. It must not come from
505
+ // findById(target): that is an exact primary-key read
506
+ // and resolves neither meta.href nor meta.url nor a
507
+ // stripped extension, so any href- or url-form target
508
+ // yields no hash and the manifest cannot tell "target
509
+ // moved" from "target changed".
511
510
  //
512
- // No hash still means "nothing resolved", and
513
- // skipDecision re-renders a hashless edge whose target
514
- // mutated — which is what should happen when a page
515
- // that was linked-to-but-missing finally appears.
511
+ // No hash means "nothing resolved", and skipDecision
512
+ // re-renders a hashless edge whose target mutated —
513
+ // which is what should happen when a page that was
514
+ // linked-to-but-missing finally appears.
516
515
  const targetIds = [...ids]
517
516
  const bound = targetIds.length === 1 ? findById(targetIds[0]) : null
518
517
  edges.push({
@@ -89,24 +89,24 @@ export function files(options = {}) {
89
89
  case ACTION.UPDATE: {
90
90
  const current = await findEntity({ id })
91
91
  // `checksum` is the source-checksum FUNCTION from the
92
- // plugin context, not a value. Comparing the stored
93
- // string against it was never equal, so the guard
94
- // always passed and every sync re-wrote the entity —
95
- // `synced = false` was unreachable.
92
+ // plugin context, not a value — it has to be called.
93
+ // Comparing the stored string against the function
94
+ // itself is never equal, which makes the guard always
95
+ // pass and `synced = false` unreachable.
96
96
  const currentChecksum = await checksum(source)
97
97
  if (current?.checksum != currentChecksum) {
98
98
  await updateEntity({
99
99
  id,
100
100
  uri,
101
- // `name` — the prefixed form, as CREATE uses.
102
- // This read `relativePath`, so an update
103
- // dropped the outputFolder prefix while
104
- // meta.url two lines down kept it. The assets
105
- // plugin builds preset destinations from
106
- // `name`, so a file replaced under watch had
107
- // its derivatives written somewhere else than
108
- // the same file freshly imported, and
109
- // meta.presets recorded the wrong path.
101
+ // `name` — the prefixed form, the same one
102
+ // CREATE uses. `relativePath` here drops the
103
+ // outputFolder prefix that meta.url two lines
104
+ // down keeps. The assets plugin builds preset
105
+ // destinations from `name`, so the two
106
+ // disagreeing sends a watched replacement's
107
+ // derivatives somewhere else than a fresh
108
+ // import's, and records the wrong path in
109
+ // meta.presets.
110
110
  name,
111
111
  collection,
112
112
  type,
@@ -159,12 +159,12 @@ export function files(options = {}) {
159
159
  // journal with phantom mutations and triggering downstream
160
160
  // re-dispatch of aggregate layouts whose recorded query deps
161
161
  // matched the collection.
162
- // --force (and a wiped catalog) must defeat the gate, the same
163
- // way source.js's gateChecksum lets them defeat its own. This
164
- // plugin carries a second, independent gate, and it honoured
165
- // neither — so no amount of forcing ever re-derived a file's
166
- // name / meta.url / meta.presets, and a catalog holding bad
167
- // `files` rows had no repair path short of deleting them.
162
+ // --force (and a wiped catalog) must defeat this gate, the
163
+ // same way source.js's gateChecksum lets them defeat its own.
164
+ // This is a second, independent gate: if it ignores them, no
165
+ // amount of forcing re-derives a file's name / meta.url /
166
+ // meta.presets, and a catalog holding bad `files` rows has no
167
+ // repair path short of deleting them.
168
168
  const forced = runtime.options.force || runtime.catalog?.cacheInvalidated
169
169
  const priorChecksums = checksumsByCollection(collection)
170
170
  await pMap(paths, async relativePath => {
package/src/plugins.js CHANGED
@@ -89,9 +89,8 @@ onLoad(() => {
89
89
  if (!factoryEntries.length && !registeredRenderers && !registeredPostprocessors) {
90
90
  // "No plugins loaded" is a legitimate state for a project with no
91
91
  // config at all, and a near-certain mistake for one that HAS a
92
- // config — the two printed the same line, so a config that
93
- // produced no plugins looked like a deliberate choice. Say which
94
- // case this is.
92
+ // config. One line for both makes a config that produced no
93
+ // plugins read as a deliberate choice, so say which case it is.
95
94
  if (runtime.options.configChecksum) {
96
95
  logger.warn(
97
96
  'No plugins loaded, but a config was read from %s — ' +
package/src/refs.js CHANGED
@@ -26,11 +26,11 @@
26
26
  // (language variants).
27
27
  //
28
28
  // Both are load-bearing. Answering "who depends on X?" from target_ref
29
- // alone means guessing which strings X could have answered to, which
30
- // fails the moment X changes its name — the recorded string is then
31
- // derivable from no live state. target_id alone cannot express a
32
- // forward or dangling reference, nor re-bind when an entity later
33
- // claims a name nobody could resolve before.
29
+ // alone means guessing which strings X could have answered to, and that
30
+ // guess fails the moment X changes its name: the recorded string is then
31
+ // derivable from no live state. target_id alone cannot express a forward
32
+ // or dangling reference, nor re-bind when an entity later claims a name
33
+ // nobody could resolve before.
34
34
  //
35
35
  // Lookups are indexed SELECTs over (target_ref, kind), (target_id) and
36
36
  // (source_id):
@@ -67,10 +67,9 @@ import { registerSchema, useDatabase } from './database/index.js'
67
67
  // already covers forward (everything X references) without a
68
68
  // separate index.
69
69
  //
70
- // Exported so tests build their index over the REAL schema instead of a
71
- // hand-copied one. The copy in test/unit/refs.test.js silently drifted
72
- // out of date the moment target_id was added — 25 tests failed with a
73
- // bare SQLITE_ERROR pointing at nothing in particular.
70
+ // Exported so tests build their index over the real schema rather than a
71
+ // copy. A copy drifts the moment a column is added, and the failure is a
72
+ // bare SQLITE_ERROR that names nothing.
74
73
  export const REFS_SCHEMA = `
75
74
  CREATE TABLE IF NOT EXISTS mikser_refs (
76
75
  source_id TEXT NOT NULL,
@@ -270,11 +269,11 @@ export function createIndex(db) {
270
269
  // entity that has just started answering to a name nobody
271
270
  // could resolve before.
272
271
  //
273
- // Union rather than replacement: strictly a superset of the
274
- // pre-binding behaviour, so nothing that invalidated before
275
- // can stop invalidating now. Over-approximating here is
276
- // cheap — manifest.skipDecision still compares hashes and
277
- // drops anything that did not actually change.
272
+ // Union rather than either alone: a superset of what each
273
+ // direction finds on its own, so no dependency is missed
274
+ // because it was recorded under the other one. Over-
275
+ // approximating here is cheap — manifest.skipDecision still
276
+ // compares hashes and drops anything that did not change.
278
277
  for (const key of lookupKeys(entity ?? { id })) {
279
278
  for (const row of stmtInboundAny.all(key)) referrers.add(row.source_id)
280
279
  }
package/src/render.js CHANGED
@@ -77,12 +77,11 @@ function lookupHrefViaDb(href) {
77
77
  function lookupUrlViaDb(ref, preset, origin, track) {
78
78
  if (typeof ref !== 'string') return ref
79
79
  const row = stmtIdLookup.get(ref)
80
- // Record the dependency here, where the row is already read, rather
81
- // than in the wrapper — a second lookup per call would be paid on
82
- // every image and media reference in a template. stmtIdLookup is an
83
- // exact-id read, so a hit means the bound entity IS `ref`; a miss
84
- // records the name with no binding, which is a forward reference and
85
- // still a real dependency.
80
+ // Record the dependency here, where the row is already read: doing it
81
+ // in the wrapper costs a second lookup on every image and media
82
+ // reference in a template. stmtIdLookup is an exact-id read, so a hit
83
+ // means the bound entity IS `ref`; a miss records the name with no
84
+ // binding, which is a forward reference and still a real dependency.
86
85
  track?.lookup?.(ref, row ? ref : null)
87
86
  if (!row) return ref
88
87
  const meta = JSON.parse(row.data).meta || {}
package/src/report.js CHANGED
@@ -34,11 +34,11 @@ export function reportRendered(entity, reason) {
34
34
  }
35
35
 
36
36
  // A render that RAN and produced bytes identical to what was already on
37
- // disk. Distinct from both other outcomes and the interesting one of the
37
+ // disk. Distinct from both other outcomes, and the interesting one of the
38
38
  // three: `rendered` means the output moved, `skipped` means the manifest
39
- // decided not to look, and this means invalidation was coarser than it
40
- // needed to be. Nothing downstream should have been disturbed, and the
41
- // count is the measure of how much conservative invalidation costs.
39
+ // declined to look, and this means invalidation was coarser than it needed
40
+ // to be. Nothing downstream is disturbed, and the count measures what
41
+ // conservative invalidation costs.
42
42
  export function reportUnchanged(entity) {
43
43
  if (!runtime.options?.json) return
44
44
  store().unchanged.push({ id: entity?.id, destination: entity?.destination ?? null })
package/src/track.js CHANGED
@@ -45,20 +45,18 @@ export function createTrack({ partial = true, query = true, lookup = true } = {}
45
45
  if (lookup) {
46
46
  // Lookups a TEMPLATE made by name: runtime.href('/contacts'),
47
47
  // runtime.lookupUrl('/media/clip.mp4'). Both read the catalog
48
- // directly, and until this existed neither told anyone — so nothing
49
- // recorded that a page depends on the page it links to.
48
+ // directly, so without recording them nothing knows that a page
49
+ // depends on the page it links to — rename the target and only the
50
+ // target re-renders, leaving every link to it pointing at a file
51
+ // that no longer exists, on a green build.
50
52
  //
51
- // Measured consequence: rename contacts.md to contact-us.md and only
52
- // the renamed page re-renders. Every page linking to it keeps a href
53
- // pointing at a file that no longer exists, on a green build.
54
- //
55
- // The edge kind is 'lookup', NOT 'ref': mikser_refs divides ownership
56
- // by kind — indexEntity owns kind='ref' (static frontmatter $-refs)
57
- // and clears it per source, while replaceDynamic owns everything
58
- // else. Writing these as 'ref' got them inserted by replaceDynamic
59
- // and then wiped by the next indexEntity, so the edge existed in
60
- // the manifest and never in the refs index — recorded, and still
61
- // never scheduling a re-render.
53
+ // The edge kind is 'lookup', NOT 'ref'. mikser_refs divides
54
+ // ownership by kind: indexEntity owns kind='ref' (static
55
+ // frontmatter $-refs) and clears it per source, replaceDynamic owns
56
+ // everything else. A render-time edge written as 'ref' is inserted
57
+ // by replaceDynamic and then wiped by the next indexEntity, so it
58
+ // lands in the manifest, never in the refs index, and schedules
59
+ // nothing.
62
60
  //
63
61
  // Records BOTH the string asked for and what it resolved to.
64
62
  // The string alone cannot survive the target renaming itself;
package/src/utils.js CHANGED
@@ -21,21 +21,18 @@ export function inputHashOf(entity) {
21
21
  meta: entity.meta ?? null,
22
22
  content: entity.content ?? null,
23
23
  // The bytes' fingerprint, for entities whose content is not in
24
- // hand. Files are the whole reason: `files()` sets meta.url on
25
- // every file entity, so `meta` is never null, so the old
26
- // "file-only entity" special case (meta == null && content ==
27
- // null) never fired for a real file — and the fall-through
28
- // hashed {meta, content} with content null. The result was an
29
- // inputHash that did not move when an image, video or download
30
- // changed on disk. The file itself is copied rather than
31
- // rendered, so nothing looked wrong; what broke was every
32
- // dependent, whose refClosure dep-hash for that file was frozen
33
- // and whose skipDecision therefore always said "unchanged".
24
+ // hand. Files are the reason it cannot be conditioned on `meta`
25
+ // being absent: `files()` stamps meta.url on every file entity,
26
+ // so a file always has meta, and a hash over {meta, content}
27
+ // alone does not move when the bytes on disk change. The file is
28
+ // copied rather than rendered, so that shows up nowhere in the
29
+ // file itself — it freezes the refClosure dep-hash of every
30
+ // dependent, whose skipDecision then always answers "unchanged".
34
31
  //
35
32
  // Excluded when content IS present: content is then the
36
33
  // authoritative copy of the same bytes, and folding in a
37
34
  // checksum computed a different way would make the hash depend
38
- // on how the checksum happens to be derived.
35
+ // on how that checksum happens to be derived.
39
36
  checksum: entity.content == null ? entity.checksum ?? null : null,
40
37
  // `inputs` is how a plugin declares bytes that are NOT part of the
41
38
  // entity's own content but that its output depends on. Whatever is
@@ -59,13 +56,12 @@ export function inputHashOf(entity) {
59
56
  // Pure: synchronous, no I/O.
60
57
  //
61
58
  // MUST stay in lockstep with `refFilter` below, which is the forward
62
- // direction of the same relation. `meta.url` used to be missing here
63
- // while refFilter had it: a `$hero: /hero.txt` ref to a served path
64
- // (ADR-0011) recorded an edge against `/hero.txt`, but the file entity
65
- // at `/files/hero.txt` produced keys `['/files/hero.txt',
66
- // '/files/hero']` — so nothing ever matched the edge and editing the
67
- // asset invalidated nothing. Every $-ref to an image, video or
68
- // download was silently non-invalidating.
59
+ // direction of the same relation. A form present there and missing here
60
+ // makes every ref written in that form silently non-invalidating: the
61
+ // edge is recorded against the string the author wrote, and nothing the
62
+ // target expands to ever matches it. `meta.url` is the one to watch —
63
+ // a `$hero: /hero.txt` ref to a served path (ADR-0011) resolves through
64
+ // it, while the file entity's id is `/files/hero.txt`.
69
65
  export function lookupKeys(entity) {
70
66
  const id = entity?.id
71
67
  if (!id) return []
@@ -935,22 +931,22 @@ export function useCollection(runtime, name) {
935
931
  // Write `bytes` to `file`, unless the file already holds exactly those
936
932
  // bytes. Returns true if it wrote, false if the file was already correct.
937
933
  //
938
- // Invalidation is deliberately conservative: an entity that merely READ
939
- // another entity re-renders when that one changes, because the engine
940
- // cannot know which field was read. That is the right default, and it
941
- // means renders regularly produce byte-identical output. Writing anyway
942
- // moves mtime, and three things downstream key off the file rather than
943
- // its contents:
934
+ // Invalidation is deliberately conservative: an entity that merely READS
935
+ // another re-renders when that one changes, because the engine cannot
936
+ // know which field was read. That is the right default, and it means
937
+ // renders regularly produce byte-identical output. Three things
938
+ // downstream key off the file rather than its contents, so writing it
939
+ // anyway is not free:
944
940
  //
945
- // - live reload watches the output folder, so editing one photograph
946
- // reloaded the browser on pages that had not changed
941
+ // - live reload watches the output folder, so one edited photograph
942
+ // reloads the browser on every page that merely mentions it
947
943
  // - rsync, `aws s3 sync` and most CDN tools compare size plus mtime,
948
944
  // so unchanged pages re-upload
949
945
  // - `find out -newer` cannot answer "what did this build change?"
950
946
  //
951
- // Doing it here rather than narrowing the dependency edges fixes every
952
- // conservative-invalidation case at once, and stays correct as the graph
953
- // gets more precise instead of becoming redundant.
947
+ // The check belongs here rather than in the dependency edges: it covers
948
+ // every conservative-invalidation case at once, and stays correct as the
949
+ // graph gets more precise instead of becoming redundant.
954
950
  //
955
951
  // Ordering matters: the size check comes first so the common
956
952
  // output-really-changed case never pays for a read, and lstat (not stat)
@@ -0,0 +1,246 @@
1
+ // A minimal in-memory harness for testing core plugins.
2
+ //
3
+ // Real plugins call lifecycle registrations (onLoaded, onProcess, onSync, ...)
4
+ // during their factory invocation; the engine later drives those callbacks. In
5
+ // tests we substitute every API surface with a recorder, then drive the
6
+ // callbacks manually with controlled inputs.
7
+
8
+ import _ from 'lodash'
9
+ import realRuntime from '../src/runtime.js'
10
+ import { matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError } from '../src/utils.js'
11
+
12
+ const OPERATION = {
13
+ CREATE: 'create',
14
+ UPDATE: 'update',
15
+ DELETE: 'delete',
16
+ RENDER: 'render',
17
+ POSTPROCESS: 'postprocess',
18
+ }
19
+ const ACTION = {
20
+ CREATE: 'create',
21
+ UPDATE: 'update',
22
+ DELETE: 'delete',
23
+ TRIGGER: 'trigger',
24
+ }
25
+ const TASKS = { INLINE: 'inline', SERIAL: 'serial', WORKER: 'worker' }
26
+
27
+ export function createHarness({
28
+ config = {},
29
+ options = {},
30
+ state = {},
31
+ entities = [],
32
+ journal = [],
33
+ } = {}) {
34
+ const hookNames = [
35
+ 'load', 'loaded', 'import', 'imported',
36
+ 'process', 'processed', 'persist', 'persisted',
37
+ 'beforeRender', 'render', 'afterRender',
38
+ 'beforePostprocess', 'postprocess', 'afterPostprocess',
39
+ 'finalize', 'finalized', 'cancel', 'cancelled',
40
+ 'complete', 'validate',
41
+ ]
42
+ const hooks = Object.fromEntries(hookNames.map(n => [n, []]))
43
+ const sync = new Map()
44
+ const watchers = []
45
+ const renderTasks = []
46
+ const postprocessTasks = []
47
+ const progress = []
48
+ const logs = []
49
+ const mklog = (level) => (...args) => logs.push({ level, args })
50
+ const logger = {
51
+ info: mklog('info'),
52
+ warn: mklog('warn'),
53
+ error: mklog('error'),
54
+ debug: mklog('debug'),
55
+ trace: mklog('trace'),
56
+ notice: mklog('notice'),
57
+ }
58
+
59
+ let nextId = 1
60
+ const journalEntries = journal.map(e => ({ id: nextId++, output: null, context: {}, options: {}, ...e }))
61
+
62
+ function addEntry({ entity, operation, context = {}, options = {} }) {
63
+ journalEntries.push({ id: nextId++, entity, operation, context, options, output: null })
64
+ }
65
+
66
+ // Stub `runtime.catalog` so catalog.js's module-level findEntity /
67
+ // findEntities / findById / queryEntities / readEntity / expand
68
+ // walker read from the harness's in-memory entity set. Real
69
+ // catalog.js's onInitialized sets up an identical shape — a
70
+ // Map<id, entity> with a no-op save() — but persists to disk; the
71
+ // stub skips persistence. Pushes/mutations to the input `entities`
72
+ // array land in the same Map via the constructor below.
73
+ //
74
+ // Set on the runtime SINGLETON imported from src/runtime.js — that's
75
+ // what catalog.js sees. Setting it on the harness-local `runtime`
76
+ // object below wouldn't help because plugin code imports the
77
+ // singleton directly. Each createHarness() call overwrites the
78
+ // singleton's `.catalog`, which is fine because tests don't run in
79
+ // parallel within a file.
80
+ const catalogStub = {
81
+ byId: new Map(entities.filter(e => e?.id).map(e => [e.id, e])),
82
+ version: 'test',
83
+ cacheInvalidated: false,
84
+ // No-op save — the harness never wants disk side-effects.
85
+ // catalog.js's onFinalized hook calls runtime.catalog.save(),
86
+ // so any test that drives the full lifecycle would blow up
87
+ // without this.
88
+ save: async () => {},
89
+ }
90
+ realRuntime.catalog = catalogStub
91
+
92
+ const runtime = {
93
+ options: { workingFolder: '/tmp/test-mikser', plugins: [], ...options },
94
+ config,
95
+ state,
96
+ hooks,
97
+ engine: { logger },
98
+ catalog: catalogStub,
99
+ // A harness stands in for an engine that has finished its first build —
100
+ // the entities a test declares are already in the catalog stub. Tests
101
+ // that want the pre-build window (the api plugin's 503 gate) set this
102
+ // to false themselves.
103
+ ready: true,
104
+ // Real lifecycle.js attaches these to the runtime via side-effect
105
+ // on import. The harness doesn't load that module, so reproduce
106
+ // them here against the in-memory journal.
107
+ create: async (entity) => addEntry({ operation: OPERATION.CREATE, entity }),
108
+ update: async (entity) => addEntry({ operation: OPERATION.UPDATE, entity }),
109
+ delete: async ({ id, collection, type }) =>
110
+ addEntry({ operation: OPERATION.DELETE, entity: { id, collection, type } }),
111
+ }
112
+ function updateEntry({ id, entity, output }) {
113
+ const entry = journalEntries.find(e => e.id === id)
114
+ if (!entry) return
115
+ if (entity !== undefined) entry.entity = entity
116
+ if (output !== undefined) entry.output = output
117
+ }
118
+ async function* useJournal(name, operations, signal) {
119
+ progress.push({ name, total: journalEntries.length })
120
+ for (const entry of journalEntries) {
121
+ if (operations?.length && !operations.includes(entry.operation)) continue
122
+ if (signal?.aborted) return
123
+ yield entry
124
+ }
125
+ }
126
+
127
+ const core = {
128
+ runtime,
129
+ useLogger: () => logger,
130
+
131
+ // Lifecycle registrations
132
+ onLoad: (cb) => hooks.load.push(cb),
133
+ onLoaded: (cb) => hooks.loaded.push(cb),
134
+ onImport: (cb) => hooks.import.push(cb),
135
+ onImported: (cb) => hooks.imported.push(cb),
136
+ onProcess: (cb) => hooks.process.push(cb),
137
+ onProcessed: (cb) => hooks.processed.push(cb),
138
+ onPersist: (cb) => hooks.persist.push(cb),
139
+ onPersisted: (cb) => hooks.persisted.push(cb),
140
+ onBeforeRender: (cb) => hooks.beforeRender.push(cb),
141
+ onRender: (cb) => hooks.render.push(cb),
142
+ onAfterRender: (cb) => hooks.afterRender.push(cb),
143
+ onBeforePostprocess: (cb) => hooks.beforePostprocess.push(cb),
144
+ onPostprocess: (cb) => hooks.postprocess.push(cb),
145
+ onAfterPostprocess: (cb) => hooks.afterPostprocess.push(cb),
146
+ onFinalize: (cb) => hooks.finalize.push(cb),
147
+ onFinalized: (cb) => hooks.finalized.push(cb),
148
+ onCancel: (cb) => hooks.cancel.push(cb),
149
+ onCancelled: (cb) => hooks.cancelled.push(cb),
150
+ onComplete: (cb) => hooks.complete.push(cb),
151
+ onSync: (name, cb) => sync.set(name, cb),
152
+ onValidate: (operations, cb) => hooks.validate.push({ operations, cb }),
153
+
154
+ // Entity helpers — write directly to the journal
155
+ createEntity: async (entity) => addEntry({ operation: OPERATION.CREATE, entity }),
156
+ updateEntity: async (entity) => addEntry({ operation: OPERATION.UPDATE, entity }),
157
+ deleteEntity: async ({ id, collection, type }) =>
158
+ addEntry({ operation: OPERATION.DELETE, entity: { id, collection, type } }),
159
+ addEntry,
160
+ addEntries: async (entries) => entries.forEach(addEntry),
161
+ updateEntry,
162
+ useJournal,
163
+ findEntity: async (query) => {
164
+ if (!query) return entities[0]
165
+ if (typeof query === 'function') return entities.find(query)
166
+ return entities.find(e => Object.entries(query).every(([k, v]) => e[k] === v))
167
+ },
168
+ // Synchronous PK lookup mirroring catalog.js's findById. Layouts'
169
+ // onBeforeRender hydrates dispatch ids through this — the harness
170
+ // serves from the same in-memory entities array.
171
+ findById: (id) => entities.find(e => e?.id === id) ?? catalogStub.byId.get(id),
172
+ findEntities: async (query) => {
173
+ if (!query) return [...entities]
174
+ if (typeof query === 'function') return entities.filter(query)
175
+ return entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v))
176
+ },
177
+ iterateEntities: async function* (query) {
178
+ // Stub — yields the same set findEntities would return,
179
+ // one entity at a time. Real impl in catalog.js chunks via
180
+ // sqlite; the harness doesn't need that fidelity because
181
+ // unit-test corpora are tiny.
182
+ const filtered = !query
183
+ ? [...entities]
184
+ : (typeof query === 'function'
185
+ ? entities.filter(query)
186
+ : entities.filter(e => Object.entries(query).every(([k, v]) => e[k] === v)))
187
+ for (const e of filtered) yield e
188
+ },
189
+
190
+ // Rendering & postprocessing — capture for assertions
191
+ renderEntities: async (tasks) => renderTasks.push(...tasks),
192
+ postprocessEntities: async (tasks) => postprocessTasks.push(...tasks),
193
+ renderEntity: async () => { },
194
+ postprocessEntity: async () => { },
195
+
196
+ // Misc
197
+ watch: (name, folder) => watchers.push({ name, folder }),
198
+ schedule: () => { },
199
+ trackProgress: () => { },
200
+ updateProgress: () => { },
201
+ stopProgress: () => { },
202
+
203
+ // Utilities (real implementations)
204
+ matchEntity,
205
+ normalize,
206
+ changeExtension,
207
+ getFormatInfo,
208
+ checksum,
209
+ AbortError,
210
+
211
+ constants: { OPERATION, ACTION, TASKS },
212
+ }
213
+
214
+ async function runHook(name, ...args) {
215
+ const callbacks = hooks[name] || []
216
+ const results = []
217
+ for (const cb of callbacks) {
218
+ results.push(await cb(...args))
219
+ }
220
+ return results
221
+ }
222
+
223
+ async function runSync(name, payload) {
224
+ const handler = sync.get(name)
225
+ if (!handler) return undefined
226
+ return handler(payload)
227
+ }
228
+
229
+ return {
230
+ core,
231
+ runtime,
232
+ logger,
233
+ logs,
234
+ sync,
235
+ watchers,
236
+ renderTasks,
237
+ postprocessTasks,
238
+ progress,
239
+ journal: journalEntries,
240
+ hooks,
241
+ runHook,
242
+ runSync,
243
+ addJournalEntry: addEntry,
244
+ constants: { OPERATION, ACTION, TASKS },
245
+ }
246
+ }