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 +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 +8 -9
- package/src/explain.js +4 -4
- package/src/manifest.js +36 -37
- package/src/plugins/files.js +19 -19
- package/src/plugins.js +2 -3
- package/src/refs.js +13 -14
- package/src/render.js +5 -6
- package/src/report.js +4 -4
- package/src/track.js +11 -13
- package/src/utils.js +25 -29
- 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
|
@@ -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
|
-
//
|
|
40
|
-
// with one bad import
|
|
41
|
-
// plugins loaded" and
|
|
42
|
-
// folder, one line
|
|
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
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
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
|
: [],
|
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
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
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
|
|
312
|
-
// stop a render — source.js's import checksum gate,
|
|
313
|
-
// dispatch filter, and this one — and force
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// a summary that
|
|
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
|
|
320
|
-
//
|
|
321
|
-
//
|
|
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
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
|
503
|
-
// over the ids, so the hash comes from the
|
|
504
|
-
// entity.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
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
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
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({
|
package/src/plugins/files.js
CHANGED
|
@@ -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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// `synced = false`
|
|
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,
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// meta.presets
|
|
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
|
|
163
|
-
// way source.js's gateChecksum lets them defeat its own.
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
|
93
|
-
//
|
|
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,
|
|
30
|
-
// fails the moment X changes its name
|
|
31
|
-
// derivable from no live state. target_id alone cannot express a
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
// cheap — manifest.skipDecision still
|
|
277
|
-
// drops anything that did not
|
|
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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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,
|
|
49
|
-
//
|
|
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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
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.
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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
|
|
939
|
-
// another
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
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
|
|
946
|
-
//
|
|
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
|
-
//
|
|
952
|
-
// conservative-invalidation case at once, and stays correct as the
|
|
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
|
+
}
|