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 +50 -13
- package/README.md +1 -0
- package/docs/diagnostics.md +289 -0
- package/package.json +1 -1
- package/src/catalog.js +54 -15
- package/src/config.js +19 -3
- package/src/explain.js +4 -4
- package/src/manifest.js +39 -27
- package/src/plugins/files.js +28 -5
- package/src/plugins.js +14 -1
- package/src/refs.js +13 -14
- package/src/render.js +5 -6
- package/src/report.js +16 -1
- package/src/track.js +11 -13
- package/src/utils.js +77 -19
- package/testing/harness.js +246 -0
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
|
|
102
|
-
(`ON DELETE CASCADE`)
|
|
103
|
-
|
|
104
|
-
`
|
|
105
|
-
|
|
106
|
-
|
|
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` —
|
|
520
|
-
- `npm run test:scenarios` —
|
|
521
|
-
(manifest skip, refs replay, watch-mode
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
535
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
const stmt = db.prepare(
|
|
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
|
-
|
|
478
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
: [],
|