mikser-io 8.3.2 → 8.3.7
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/9.0-PLAN.md +497 -0
- package/package.json +1 -1
- package/src/catalog.js +29 -0
- package/src/database/index.js +40 -18
- package/src/engine.js +14 -1
- package/src/plugins/files.js +27 -6
package/9.0-PLAN.md
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# Mikser 9.0 — Planning
|
|
2
|
+
|
|
3
|
+
**The shift in one line:** `mikser-io` moves from "AI-native SSG" to "file-based knowledge substrate with AI superpowers." Layouts (and the other rendering plugins currently bundled in `src/plugins/`) move out to their own packages. A new AI substrate plugin family (`mikser-io-transformers` and friends) registers inference pipelines that consumer plugins compose against.
|
|
4
|
+
|
|
5
|
+
This is a planning document, not an ADR. The ADR(s) land after the move so the wording reflects what actually shipped. This file gets dropped at release.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
The OCR + structured-extraction conversation forced the question: when "drop a folder of PDFs and ask an AI agent about them" works as a primary use case, is layouts still in the substrate's critical path? No. SSG is one composition of the substrate, not the substrate itself.
|
|
10
|
+
|
|
11
|
+
Re-running ADR-0006's five-test against `layouts`:
|
|
12
|
+
|
|
13
|
+
| test | result |
|
|
14
|
+
|---|---|
|
|
15
|
+
| 1. Substrate? Engine genuinely needs this | **No** — document/knowledge consumers don't render layouts |
|
|
16
|
+
| 2. Strengthens strategy? Load-bearing for positioning | **Was. Isn't post-shift.** |
|
|
17
|
+
| 3. God-plugin check? Engine becoming kitchen sink | **Yes** — ~900 LOC of pagination + sidecar + sitemap on top of dispatch |
|
|
18
|
+
| 4. Composability? Could ship external | **Yes** — only uses public surfaces |
|
|
19
|
+
| 5. Release cadence? Same as engine | **No** — iterates at SSG-feature cadence |
|
|
20
|
+
|
|
21
|
+
Three fails. Test 2 became a fail when positioning shifted. The architecture doesn't believe in layouts-in-core anymore; only inertia does.
|
|
22
|
+
|
|
23
|
+
## What stays in the engine
|
|
24
|
+
|
|
25
|
+
`src/plugins/` slims from 14 directories to 5:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
documents · files · front-matter · yaml · json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
These are entity-sourcing primitives — any consumer reading from a working folder needs them. Plus the substrate code (`runtime.js`, `engine.js`, `lifecycle.js`, `catalog.js`, `refs.js`, `manifest.js`, `journal.js`, `database/`, `source.js`, `subscriptions.js`, `track.js`, `render.js`/`postprocess.js` as dispatch shims — the actual rendering lives in plugins).
|
|
32
|
+
|
|
33
|
+
## What moves out
|
|
34
|
+
|
|
35
|
+
Each becomes its own package with its own version cadence:
|
|
36
|
+
|
|
37
|
+
| package | what moves | approx. LOC |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `mikser-io-layouts` | layout matching + pagination + sidecar protocol | ~900 |
|
|
40
|
+
| `mikser-io-assets` | preset processing + binary pipelines | ~600 |
|
|
41
|
+
| `mikser-io-resources` | external resource fetching | ~400 |
|
|
42
|
+
| `mikser-io-preview` | in-memory render cache + `GET /preview/` | ~250 |
|
|
43
|
+
| `mikser-io-data` | JSON catalog exports | ~150 |
|
|
44
|
+
| `mikser-io-observer` | HTTP source polling | ~200 |
|
|
45
|
+
| `mikser-io-mapper` | config-driven field rewrites | ~100 |
|
|
46
|
+
| `mikser-io-validator` | custom validation hooks | ~100 |
|
|
47
|
+
| `mikser-io-commands` | cron jobs | ~150 |
|
|
48
|
+
| `mikser-io-shares` | multi-output replication | ~200 |
|
|
49
|
+
|
|
50
|
+
Engine LOC drops ~40%. None of these plugins is wrong to ship — they're just not substrate.
|
|
51
|
+
|
|
52
|
+
## New: AI substrate
|
|
53
|
+
|
|
54
|
+
The substrate exposes a verb-level pipelines surface:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
runtime.options.pipelines = {
|
|
58
|
+
embed: (text, opts?) => Promise<number[]>,
|
|
59
|
+
imageEmbed: (imageBuffer, opts?) => Promise<number[]>,
|
|
60
|
+
ocr: (input, opts?) => Promise<string>,
|
|
61
|
+
extract: (input, { schema, prompt }, opts?) => Promise<unknown>,
|
|
62
|
+
classify: (text, labels, opts?) => Promise<{ label, score }[]>,
|
|
63
|
+
rerank: (query, docs, opts?) => Promise<{ idx, score }[]>,
|
|
64
|
+
summarize: (text, opts?) => Promise<string>,
|
|
65
|
+
transcribe: (audioBuffer, opts?) => Promise<string>,
|
|
66
|
+
translate: (text, { from, to }, opts?) => Promise<string>,
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
All pipeline functions accept `{ signal }` so consumer plugins can pass through the cycle's abort signal. Watch-mode cancellations short-circuit pending inference cleanly.
|
|
71
|
+
|
|
72
|
+
### Provider plugins
|
|
73
|
+
|
|
74
|
+
Each provider plugin registers only the pipelines it can support:
|
|
75
|
+
|
|
76
|
+
| plugin | typical pipelines | runtime requirement |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `mikser-io-transformers` | embed, imageEmbed, ocr, extract, classify, rerank, summarize, transcribe | `@xenova/transformers`; first-run model download |
|
|
79
|
+
| `mikser-io-openai` | embed, extract (via JSON mode), classify, summarize, transcribe | `OPENAI_API_KEY` |
|
|
80
|
+
| `mikser-io-ollama` | embed, summarize (depends on pulled models) | ollama daemon |
|
|
81
|
+
| `mikser-io-anthropic` | extract, classify, summarize | `ANTHROPIC_API_KEY` |
|
|
82
|
+
|
|
83
|
+
Consumer plugins gate on what's registered:
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
onLoaded(() => {
|
|
87
|
+
if (!runtime.options.pipelines?.ocr) {
|
|
88
|
+
logger.warn(
|
|
89
|
+
'mikser-io-ocr needs an ocr pipeline — install mikser-io-transformers ' +
|
|
90
|
+
'(or another provider that registers ocr).'
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
// wire up
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Same gate pattern the api / preview / mcp plugins already use against `runtime.options.app` / `runtime.options.mcp`.
|
|
99
|
+
|
|
100
|
+
## New consumer plugins
|
|
101
|
+
|
|
102
|
+
| package | needs pipeline | what it does |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `mikser-io-vector` | embed | semantic search over text (refactored to depend on substrate) |
|
|
105
|
+
| `mikser-io-ocr` | ocr | populate `entity.content` from PDF/image source files |
|
|
106
|
+
| `mikser-io-extract` | extract | schema-driven structured extraction into `entity.meta` |
|
|
107
|
+
| `mikser-io-image-search` | imageEmbed | CLIP-based image search; text query → matching images |
|
|
108
|
+
| `mikser-io-rerank` | rerank | post-process vector search results with cross-encoder |
|
|
109
|
+
| `mikser-io-summarize` | summarize | auto-populate `meta.summary` when authors don't |
|
|
110
|
+
| `mikser-io-classify` | classify | zero-shot tag suggestions per entity create/update |
|
|
111
|
+
| `mikser-io-transcribe` | transcribe | podcasts/videos → searchable transcripts |
|
|
112
|
+
| `mikser-io-translate` | translate | multilingual content |
|
|
113
|
+
|
|
114
|
+
Each is small — 100-400 LOC. The substrate handles model lifecycle (download, cache, progress, dimension introspection); the consumer handles the entity-lifecycle integration.
|
|
115
|
+
|
|
116
|
+
## Format liberation: source ↔ render symmetry
|
|
117
|
+
|
|
118
|
+
Mikser already has rendering plugins that emit entities AS specific formats (`render-hbs`, `render-eta`, `render-liquid`, `render-markdown`, `render-file`). The 9.0 lens reveals the missing other half: matching source plugins that parse FROM specific formats INTO entities. The substrate becomes a **format translation layer** — anything in, anything out, all composing on the same catalog.
|
|
119
|
+
|
|
120
|
+
### The symmetry
|
|
121
|
+
|
|
122
|
+
| source plugin (format → entities) | render plugin (entities → format) | format |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `documents` (md/html/yml/json) | `render-hbs`, `render-eta`, `render-liquid`, `render-markdown` | text documents |
|
|
125
|
+
| `files` (any binary) | `post-pdf`, `post-mjml` | binary outputs |
|
|
126
|
+
| `mikser-io-csv` *(new)* | `mikser-io-render-csv` *(new)* | CSV |
|
|
127
|
+
| existing (api plugin's data exports) | `mikser-io-data` *(extracted in 9.0)* | JSON snapshots |
|
|
128
|
+
| `mikser-io-ocr` + `mikser-io-extract` (PDF/image → entities) | `mikser-io-render-pdf` *(future)* | PDF documents |
|
|
129
|
+
| future: `mikser-io-ical`, `mikser-io-vcard`, `mikser-io-rss` | future: matching renderers | calendars, contacts, feeds |
|
|
130
|
+
|
|
131
|
+
The substrate doesn't care which side a format lives on. The catalog's job is to hold queryable entities; source plugins fill it from whatever the world produces; render plugins emit whatever the world consumes.
|
|
132
|
+
|
|
133
|
+
### The round-trip that demonstrates it
|
|
134
|
+
|
|
135
|
+
A folder of scanned PDF invoices walks through the entire stack and lands as a CSV for the accountant — without anyone writing pipeline code:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
PDFs in documents/invoices/
|
|
139
|
+
↓ documents plugin (file → entity, raw)
|
|
140
|
+
↓ mikser-io-ocr (OCR pipeline populates entity.content)
|
|
141
|
+
↓ mikser-io-extract (schema-driven, populates entity.meta)
|
|
142
|
+
→ catalog now holds typed invoice entities
|
|
143
|
+
↓ mikser-io-render-csv (entities → rows in invoices.csv)
|
|
144
|
+
+ mikser-io-vector (semantic search across invoices)
|
|
145
|
+
+ mikser-io-mcp (agent answers "which Q1 vendors charged us most")
|
|
146
|
+
+ mikser-io-api (HTTP /api/public/entities/invoices)
|
|
147
|
+
|
|
148
|
+
Each individual file → live-reload on watch-mode
|
|
149
|
+
A schema change → re-extraction on next cycle
|
|
150
|
+
A new PDF dropped in → automatic ingest + index + export
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Edit a PDF, save it, the CSV regenerates with the corrected row a couple seconds later. The accountant pulls `invoices.csv` from a synced folder. The AI agent answers questions. The API exposes the data. **All from the same canonical source — the PDFs on disk.**
|
|
154
|
+
|
|
155
|
+
### Why this is a positioning shift, not just a feature list
|
|
156
|
+
|
|
157
|
+
Other tools trap your data in their source format:
|
|
158
|
+
|
|
159
|
+
- **Notion** — your data lives in their proprietary database tables; export is a deliberate, lossy operation
|
|
160
|
+
- **Airtable** — same shape with a different paint job
|
|
161
|
+
- **Glean** — indexes your data but you can't re-emit it in another format cleanly
|
|
162
|
+
- **OneDrive / Dropbox / Box** — store files but don't make their content queryable
|
|
163
|
+
|
|
164
|
+
Mikser flips this. Source format is where data enters; **what shape it takes after that is the user's call.** PDF in → CSV out. CSV in → HTML out. Markdown in → semantic embeddings out. Anything in → anything out, with watch-mode keeping every output current as the inputs change.
|
|
165
|
+
|
|
166
|
+
The 9.0 plugin set turns this from architectural potential into a shipped product. With `csv`, `render-csv`, `ocr`, `extract`, `transformers`, `vector`, and the existing render-* family, mikser is a real "knowledge format translation substrate" rather than "an SSG that has some AI hooks."
|
|
167
|
+
|
|
168
|
+
### What lands in 9.0 specifically
|
|
169
|
+
|
|
170
|
+
| package | role | LOC est. |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `mikser-io-csv` | source: CSV file → row entities (one entity per row; `idColumn` config for stable ids) | ~250 |
|
|
173
|
+
| `mikser-io-render-csv` | render: entities → CSV file (RFC 4180 escaping; column ordering from sidecar) | ~150 |
|
|
174
|
+
|
|
175
|
+
Future render/source pairs (post-9.0, demand-driven): `render-pdf`, `render-rss`, `render-ical`, `ical` source, `vcard` source. Each ~150-300 LOC. The substrate carries them.
|
|
176
|
+
|
|
177
|
+
## Streaming render output
|
|
178
|
+
|
|
179
|
+
Row-as-entity (`mikser-io-csv`, 1M-row catalogs) breaks an assumption the current render path quietly relies on: that the output of `render(entity)` is a string or `Buffer` you can hand to `writeFile`. A render that serializes 1M rows to a single 500MB CSV string is the worst kind of failure mode — it works in dev with 100 rows, OOMs in production.
|
|
180
|
+
|
|
181
|
+
The substrate already streams **input** (`iterateEntities` is a seek-paginated async generator, ADR-0009 phase 5). Output is the other half.
|
|
182
|
+
|
|
183
|
+
### What changes in the render dispatch
|
|
184
|
+
|
|
185
|
+
`render.js` currently expects each renderer to return `string | Buffer`. Under 9.0 the contract widens:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
return string // current path — buffer to disk
|
|
189
|
+
return Buffer // current path — buffer to disk
|
|
190
|
+
return Readable // new — pipe to a write stream
|
|
191
|
+
return AsyncIterable<string | Buffer> // new — drained into a write stream
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The dispatcher inspects the return value and routes:
|
|
195
|
+
|
|
196
|
+
- string/Buffer → `writeFile(destination, result)` (unchanged hot path; everything that exists today keeps working)
|
|
197
|
+
- Readable or async iterable → `await pipeline(result, createWriteStream(destination))`
|
|
198
|
+
|
|
199
|
+
Postprocess plugins (`render-file` for inlining, `mjml`, `pdf`, etc.) opt out of the streaming path automatically when they need a buffered input — they declare `bufferedInput: true` and the dispatcher collects the stream first. Most postprocess plugins already need the full document (HTML rewrite, MJML compile), so this is the common case; streaming is the optimization for renderers whose output is naturally append-only.
|
|
200
|
+
|
|
201
|
+
### What this enables
|
|
202
|
+
|
|
203
|
+
| renderer | streaming wins because |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `render-csv` | row count is corpus-scale; output is line-by-line append; no postprocess for CSV |
|
|
206
|
+
| `render-rss` / `render-atom` | item list is corpus-scale; XML is append-only per item |
|
|
207
|
+
| `render-ical` | event list is corpus-scale |
|
|
208
|
+
| `data` (JSON catalog export) | the corpus IS the output; current code buffers the whole catalog |
|
|
209
|
+
| future `render-ndjson` | naturally one entity per line |
|
|
210
|
+
| `render-hbs` / `render-eta` / `render-markdown` | NOT a win — template engines produce one document, postprocess (front-matter inlining, link rewriting) needs the full string anyway |
|
|
211
|
+
|
|
212
|
+
### Memory budget the streaming path holds
|
|
213
|
+
|
|
214
|
+
At 1M rows, `render-csv` running in streaming mode should never hold more than one row's worth of formatted output in memory. The peak isn't the row count, it's the slowest downstream consumer (disk write speed). On a SATA SSD, a 500MB CSV writes at ~400MB/sec — under 2 seconds wall-clock for the full file, with the JS heap never crossing 50MB for the render itself.
|
|
215
|
+
|
|
216
|
+
The buffered path on the same workload: 500MB CSV string in V8 = ~1GB after the UTF-16 doubling, then a second 500MB allocation for the `Buffer` conversion, then GC pressure. Streaming isn't a 10% optimization here; it's the difference between working and OOM.
|
|
217
|
+
|
|
218
|
+
### What this doesn't change
|
|
219
|
+
|
|
220
|
+
- HTML rendering paths (the dominant workload) keep returning strings. No measurable cost.
|
|
221
|
+
- The journal's auto-persist diff still works — it diffs the entity object, not the output.
|
|
222
|
+
- Manifest checksumming streams over the output too (`createHash` accepts piped data) — no extra read.
|
|
223
|
+
|
|
224
|
+
### Scope for 9.0
|
|
225
|
+
|
|
226
|
+
Land the dispatcher change in the engine alongside `mikser-io-csv` + `mikser-io-render-csv`. The CSV plugins are the first consumers; they validate the contract. `data` plugin migrates to streaming as a second step (separate release). All existing renderers stay on the string path; the widening is additive.
|
|
227
|
+
|
|
228
|
+
Add to the work list: dispatcher widening + `render-csv` shipping in streaming mode as the proof.
|
|
229
|
+
|
|
230
|
+
## Schemas plugin pulls real weight here
|
|
231
|
+
|
|
232
|
+
`mikser-io-schemas` has been sitting idle. Under the 9.0 shape it becomes load-bearing:
|
|
233
|
+
|
|
234
|
+
- Declares entity shapes via zod
|
|
235
|
+
- `extract: true` flag per schema turns the declaration into an auto-extraction target
|
|
236
|
+
- `mikser-io-extract` walks new entities, looks up the schema for the entity's `meta.layout`, calls `runtime.options.pipelines.extract(input, { schema })`, validates the result, populates `entity.meta`
|
|
237
|
+
- Validated structured data becomes queryable through standard indexed columns
|
|
238
|
+
|
|
239
|
+
Drop a folder of PDF invoices → schema declares `{vendor, date, items, total}` → catalog has fully-typed invoice rows → `findEntities({'meta.layout': 'invoice', 'meta.total': {$gt: 5000}})` returns them.
|
|
240
|
+
|
|
241
|
+
The schemas plugin also needs the lifecycle-ordering bug fixed before this lands (currently `onValidate` fires before `front-matter` populates `meta`). Separate but related work.
|
|
242
|
+
|
|
243
|
+
## Recipe compositions
|
|
244
|
+
|
|
245
|
+
**SSG** (today's flagship — same shape, different distribution):
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
plugins: [
|
|
249
|
+
'documents', 'files', 'front-matter', 'yaml',
|
|
250
|
+
'layouts',
|
|
251
|
+
'render-hbs', 'render-markdown', 'render-href',
|
|
252
|
+
'assets', 'resources', 'preview',
|
|
253
|
+
'data',
|
|
254
|
+
]
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Document management system**:
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
plugins: [
|
|
261
|
+
'documents', 'files', 'front-matter', 'yaml',
|
|
262
|
+
'transformers', 'ocr', 'extract',
|
|
263
|
+
'schemas',
|
|
264
|
+
'vector',
|
|
265
|
+
'api', 'mcp',
|
|
266
|
+
]
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Personal knowledge management**:
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
plugins: [
|
|
273
|
+
'documents', 'files', 'front-matter', 'yaml',
|
|
274
|
+
'transformers', 'ocr', 'extract', 'schemas',
|
|
275
|
+
'vector',
|
|
276
|
+
'mcp',
|
|
277
|
+
]
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
No HTTP — Claude Desktop talks MCP directly. An Obsidian competitor in ~8 plugins.
|
|
281
|
+
|
|
282
|
+
**Photo library with semantic search**:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
plugins: [
|
|
286
|
+
'files',
|
|
287
|
+
'transformers', 'image-search',
|
|
288
|
+
'api', 'mcp',
|
|
289
|
+
]
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
No `documents` even. Just files + image embeddings + access.
|
|
293
|
+
|
|
294
|
+
**Tabular data workflow** (the round-trip from the format-liberation section):
|
|
295
|
+
|
|
296
|
+
```js
|
|
297
|
+
plugins: [
|
|
298
|
+
'documents', 'files', 'front-matter', 'yaml',
|
|
299
|
+
'csv', // CSV files → row entities
|
|
300
|
+
'transformers', 'ocr', 'extract', // PDFs → row entities via schema
|
|
301
|
+
'schemas',
|
|
302
|
+
'render-csv', 'render-hbs', // entities → CSV + HTML
|
|
303
|
+
'data',
|
|
304
|
+
'api', 'mcp',
|
|
305
|
+
]
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
PDF invoices and CSV exports land as the same entity shape. Editor renders a dashboard HTML page; accountant downloads regenerated CSV; agent answers semantic questions. One source of truth, three output formats.
|
|
309
|
+
|
|
310
|
+
**Newsletter platform**:
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
plugins: [
|
|
314
|
+
'documents', 'files', 'front-matter', 'yaml',
|
|
315
|
+
'layouts', 'render-hbs',
|
|
316
|
+
'post-mjml', 'post-mailchimp',
|
|
317
|
+
'data',
|
|
318
|
+
]
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Each composition is a coherent product. None of them carry weight they don't need.
|
|
322
|
+
|
|
323
|
+
## Migration shape
|
|
324
|
+
|
|
325
|
+
ADR-0002 (files-as-truth) plus the schema-version recovery pattern shipped in 8.3.7 carry the migration. The engine knows the world has changed and tells the user how to fix it.
|
|
326
|
+
|
|
327
|
+
1. **`mikser-io@9.0.0`** — `src/plugins/` slimmed to the five engine plugins. Legacy stub modules at the old paths throw a clear error:
|
|
328
|
+
|
|
329
|
+
```
|
|
330
|
+
Plugin "layouts" not found in src/plugins/. It has moved to its own
|
|
331
|
+
package — run: npm install mikser-io-layouts
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`schema_version` stamp bumps; auto-recover wipes the cache on first run (no manual `--clear` needed, per 8.3.7).
|
|
335
|
+
|
|
336
|
+
2. **New plugin repos shipped at the same time**:
|
|
337
|
+
- `mikser-io-layouts` (the big one)
|
|
338
|
+
- `mikser-io-assets`, `mikser-io-resources`, `mikser-io-preview`, `mikser-io-data`
|
|
339
|
+
- `mikser-io-observer`, `mikser-io-mapper`, `mikser-io-validator`, `mikser-io-commands`, `mikser-io-shares`
|
|
340
|
+
|
|
341
|
+
3. **New AI plugin repos**:
|
|
342
|
+
- `mikser-io-transformers` (substrate)
|
|
343
|
+
- `mikser-io-ocr`, `mikser-io-extract` (consumer plugins for the document workflow)
|
|
344
|
+
- Others land on rolling cadence as demand justifies
|
|
345
|
+
|
|
346
|
+
4. **`mikser-io-vector` bumps to 2.0.0** — drops its OpenAI HTTP client, depends on the pipelines substrate. Old config keys (`vector.openai.apiKey`) keep working through a one-time deprecation cycle if `mikser-io-openai` is loaded; recommended path is `mikser-io-transformers` (no API key, fully offline).
|
|
347
|
+
|
|
348
|
+
5. **README rewrite** — "Getting Started" splits into recipes. The current single-default-everyone-gets-SSG path becomes one of several. Each recipe shows its plugin list, the workflow it enables, and links to an example repo.
|
|
349
|
+
|
|
350
|
+
6. **Example repos**:
|
|
351
|
+
- `mikser-io-example-blog` (existing — the SSG recipe)
|
|
352
|
+
- `mikser-io-example-dms` (new — document management)
|
|
353
|
+
- `mikser-io-example-pkm` (new — personal knowledge management)
|
|
354
|
+
- `mikser-io-example-photos` (new — image library)
|
|
355
|
+
|
|
356
|
+
## `mikser --install` — explicit plugin installation
|
|
357
|
+
|
|
358
|
+
The plugin count balloons from "everyone uses the default" to "everyone composes a recipe." A document management recipe is 10 packages; an SSG recipe is 12. Without a clean install story, every recipe shift is "edit `package.json`, `npm install`, hit a missing plugin, repeat."
|
|
359
|
+
|
|
360
|
+
But the engine shouldn't shell out to a package manager during normal runs. No magic, no surprise installs as a side effect of `mikser`. Package management is its own step, run explicitly.
|
|
361
|
+
|
|
362
|
+
### Shape
|
|
363
|
+
|
|
364
|
+
`mikser --install` reads `mikser.config.js`, identifies which plugins aren't resolvable via the existing four-step lookup chain, batches them into one install call, and exits. No `onInitialize`, no `onLoaded`, no render, no watch — the substrate doesn't even open `mikser.sqlite`. Just dependency resolution and the package install.
|
|
365
|
+
|
|
366
|
+
```
|
|
367
|
+
$ mikser --install
|
|
368
|
+
|
|
369
|
+
🟡 Plugins not installed locally: layouts, vector, transformers
|
|
370
|
+
Resolving to: mikser-io-layouts, mikser-io-vector, mikser-io-transformers
|
|
371
|
+
Running: npm install --save mikser-io-layouts mikser-io-vector mikser-io-transformers
|
|
372
|
+
[npm output...]
|
|
373
|
+
✓ Installed 3 plugins (4.2s)
|
|
374
|
+
|
|
375
|
+
$ mikser
|
|
376
|
+
🟢 Continuing normally...
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Plain `mikser` without `--install` does **not** auto-install. If a plugin is missing, the engine fails with a clear error pointing at the install command:
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
$ mikser
|
|
383
|
+
|
|
384
|
+
🔴 Plugin "layouts" not found. Install with:
|
|
385
|
+
mikser --install
|
|
386
|
+
|
|
387
|
+
(or directly: npm install mikser-io-layouts)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The fallback path is the same as the schema-version recovery shipped in 8.3.7 — engine explains exactly what's wrong and exactly how to fix it. The fix is one command.
|
|
391
|
+
|
|
392
|
+
### Implementation notes
|
|
393
|
+
|
|
394
|
+
- **Package name resolution.** Plugin name `layouts` → package `mikser-io-layouts`. Same convention as today's loader. User can override per-plugin in config when they need a non-standard package name:
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
plugins: [
|
|
398
|
+
'documents',
|
|
399
|
+
{ name: 'custom', package: 'my-org-mikser-custom' },
|
|
400
|
+
]
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
- **Package manager detection.** Detect from lock file in the working folder:
|
|
404
|
+
- `package-lock.json` → npm
|
|
405
|
+
- `yarn.lock` → yarn
|
|
406
|
+
- `pnpm-lock.yaml` → pnpm
|
|
407
|
+
- `bun.lockb` → bun
|
|
408
|
+
- Default to npm if no lock file detected. CLI override via `--package-manager` for edge cases.
|
|
409
|
+
|
|
410
|
+
- **Batched install.** Scan all missing plugins, install in one call. Ten missing plugins → one network round-trip, not ten.
|
|
411
|
+
|
|
412
|
+
- **Persistence via `--save`.** Updates `package.json` so the deps are recorded. Future `mikser` invocations find them via normal node resolution. No state hidden in `runtime/` or anywhere else.
|
|
413
|
+
|
|
414
|
+
- **Version pinning.** Pin to the engine's matching major: `mikser-io@9.x` installs `mikser-io-layouts@^9`. Prevents accidental cross-major drift. Users can override per-plugin with explicit version in the config object form (`{name, package, version}`).
|
|
415
|
+
|
|
416
|
+
- **Safe-by-name.** Only resolve plugin names matching the `mikser-io-*` convention (or the explicit `package:` override). Catches typos before installing random packages — `plugins: ['layoutz']` errors at resolution time instead of silently installing `mikser-io-layoutz`.
|
|
417
|
+
|
|
418
|
+
- **Network failure → clear error.** No network, no install. Standard npm error with the engine's note: "Run when you have network connectivity, or install the listed packages manually."
|
|
419
|
+
|
|
420
|
+
- **`--install` is loud.** The install step logs every resolved package name, the exact command run, the duration, and the result. The user explicitly asked for this; show them what happened.
|
|
421
|
+
|
|
422
|
+
### CLI
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
mikser # normal run; fails clearly on missing plugins
|
|
426
|
+
mikser --install # resolve + install missing plugins; exit
|
|
427
|
+
mikser --install --package-manager=pnpm # override lock-file detection
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
That's the whole surface. No `--no-auto-install` (nothing to disable). No `NODE_ENV` branch (no behavior to gate on environment).
|
|
431
|
+
|
|
432
|
+
### Why no auto-install
|
|
433
|
+
|
|
434
|
+
Two compounding reasons:
|
|
435
|
+
|
|
436
|
+
1. **No magic during normal runs.** `mikser` is for building. `mikser --install` is for installing. One command does one thing. The engine doesn't shell out to npm as a side effect of starting the lifecycle — that would surprise CI, surprise production deploys, surprise air-gapped environments, and surprise anyone debugging "why did `mikser` take 30 seconds to start?"
|
|
437
|
+
|
|
438
|
+
2. **It matches the rest of the npm ecosystem.** `node app.js` doesn't auto-install missing packages. `next build` doesn't. `vite` doesn't. Adding auto-install to mikser would be the odd one out for no real win — the install command is one line, the error message tells you exactly what to run, the workflow is "edit config → `mikser --install` → `mikser`" which is the same shape every JS project already follows.
|
|
439
|
+
|
|
440
|
+
The friction the auto-install would have saved is one explicit command after editing the plugin list. The cost would have been: engine that may install packages during `mikser`, harder-to-reason-about CI, NODE_ENV branches, watch-mode interaction edge cases, and a "what just got installed?" question on every cold cycle.
|
|
441
|
+
|
|
442
|
+
The trade isn't worth it. `--install` as the explicit, opt-in command is the right shape.
|
|
443
|
+
|
|
444
|
+
### What this isn't
|
|
445
|
+
|
|
446
|
+
- **Not a package manager.** Mikser shells out to npm/yarn/pnpm/bun for the install. No proprietary install logic, no opinion about lockfiles, no version solver. The engine just identifies which packages need to be installed and asks the user's existing tool to do it.
|
|
447
|
+
- **Not a cross-major migration tool.** Pins to the matching major. Pre-9 plugins don't get upgraded through this path; users explicitly `npm install mikser-io-X@9` if they need to.
|
|
448
|
+
|
|
449
|
+
## Out of scope / deferred
|
|
450
|
+
|
|
451
|
+
- **ADR-0010** ("the engine is the substrate; rendering is a plugin"). Written when the move is done so the wording reflects what shipped, not a forward-projection. CLAUDE.md note added at that point too.
|
|
452
|
+
- **Plugin marketplace / discovery**. Not building this. README recipes + ecosystem docs handle discovery. Anyone shipping a third-party plugin uses the existing `mikser-io-<name>` convention.
|
|
453
|
+
- **Automated migration tool**. The legacy stub errors give the `npm install` command. That's the migration tool. Users running `mikser` after upgrade get told what to install per missing plugin; one `npm install` per package gets them running again.
|
|
454
|
+
|
|
455
|
+
## Open questions to settle before shipping
|
|
456
|
+
|
|
457
|
+
1. **Naming.** "Engine plugins" vs "substrate" vs "core" — what's the term we use in the README to distinguish what ships in `mikser-io` from what doesn't? Working proposal: **"substrate"** for the engine-only pieces (catalog, refs, manifest, journal, the 5 entity-sourcing plugins), **"plugins"** for everything else (internal or external — no asymmetry in language because there's no asymmetry in mechanism).
|
|
458
|
+
|
|
459
|
+
2. **Pipeline signal contract.** All pipeline functions accept `{ signal }` for abort. Required by the substrate's TypeScript types? Or convention with the substrate enforcing at registration time? Working proposal: **required** — the substrate validates registered functions have the signature.
|
|
460
|
+
|
|
461
|
+
3. **Multiple provider plugins loaded at once.** Can `mikser-io-transformers` AND `mikser-io-openai` both register `embed`? If yes, which one wins for the shared verb name, and can consumers explicitly request one vendor? Working proposal: **registration order wins for the shared name**. Consumer can reach a specific provider via `runtime.options.pipelines.embed.openai(text)` for vendor-specific cases. The substrate exposes both.
|
|
462
|
+
|
|
463
|
+
4. **Schemas plugin integration with extract.** `mikser-io-extract` needs to read schema declarations from `mikser-io-schemas` to know what shape to coerce into. Two options:
|
|
464
|
+
- (a) schemas plugin exposes `runtime.options.schemas.lookup(entity)` → returns the matching zod schema. Extract consumes that surface.
|
|
465
|
+
- (b) schemas plugin enriches entities with the schema reference attached; extract reads it off the entity.
|
|
466
|
+
Working proposal: **(a)** — lookup surface. Keeps the schema declaration in one place; extract reads on demand.
|
|
467
|
+
|
|
468
|
+
5. **Version bump cascade.** `mikser-io` 9.0, `mikser-io-vector` 2.0, layouts/assets/etc. 1.0 (initial extraction releases). Coordinated or independent? Working proposal: **coordinated for the initial 9.0 set** (so users can `npm install` a consistent slate); independent cadence after.
|
|
469
|
+
|
|
470
|
+
6. **What about `mikser-io-render-*`?** Those already live external. The question: do they stay shipped from their own repos as before, or get consolidated under `mikser-io-layouts` as siblings? Working proposal: **stay external as today** — they're rendering-engine adapters (hbs / eta / liquid / markdown / file), one per template language, and consolidating them serves nobody.
|
|
471
|
+
|
|
472
|
+
7. **Postprocess opt-in vs opt-out for buffered input.** The streaming render dispatcher needs to know whether a postprocess plugin can consume a stream. Two shapes:
|
|
473
|
+
- (a) postprocess plugins declare `bufferedInput: true` on registration; default is "can stream"
|
|
474
|
+
- (b) postprocess plugins declare `streamInput: true`; default is "needs buffer" (current behavior)
|
|
475
|
+
Working proposal: **(b)** — default to buffered so existing plugins (mjml, pdf, front-matter-rewrite) keep working without code changes. Plugins opt into streaming only when the format genuinely supports it (CSV row-rewrite, NDJSON line-transform). One-line migration cost beats silently breaking the plugin set.
|
|
476
|
+
|
|
477
|
+
8. **Worker path for streaming renders.** TASKS.WORKER serializes return values through Piscina's structured clone — a Readable stream doesn't survive that boundary. Working proposal: **streaming renders are INLINE/SERIAL only for 9.0**. A renderer that declares streaming output is force-dispatched on the main thread regardless of its `task:` frontmatter. Documented as a limitation, not a bug. Revisit when Piscina ships transferable-stream support or we move to MessagePort-based piping (post-9.0).
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
## Concretely, what work this is
|
|
481
|
+
|
|
482
|
+
Roughly in dependency order:
|
|
483
|
+
|
|
484
|
+
1. **mikser-io engine** — slim `src/plugins/`, add legacy stub error files at the old paths, document the `runtime.options.pipelines` surface in `documentation/api-reference.md`. Pipeline functions are no-op until a provider plugin registers them. **`mikser --install` command** (see section above) lands here too — same release, so users have a one-command way to populate a recipe's plugin set after editing config. **Streaming render output**: widen the render dispatcher in `src/render.js` to accept `Readable` and `AsyncIterable` returns; pipe to `createWriteStream` instead of `writeFile`. Postprocess plugins declare `bufferedInput: true` to opt back into the buffered path. String/Buffer returns unchanged.
|
|
485
|
+
2. **mikser-io-transformers** — new repo. Wire @xenova/transformers; register embed, ocr, extract, classify, rerank, summarize, transcribe. Handle model download, cache directory, progress bar, dimension introspection.
|
|
486
|
+
3. **mikser-io-vector 2.0** — drop OpenAI HTTP client. Depend on substrate. Validate dimension against the registered embed pipeline.
|
|
487
|
+
4. **mikser-io-layouts** — new repo. Extract `src/plugins/layouts.js` verbatim. CI + tests.
|
|
488
|
+
5. **mikser-io-assets, mikser-io-resources, mikser-io-preview, mikser-io-data** — new repos. Verbatim extraction with their own version cadence.
|
|
489
|
+
6. **mikser-io-ocr** — new repo. Consumer plugin for the ocr pipeline.
|
|
490
|
+
7. **mikser-io-extract** — new repo. Consumer plugin for the extract pipeline. Wire into mikser-io-schemas's `lookup()` surface.
|
|
491
|
+
8. **mikser-io-csv** — new repo. Source plugin: each CSV row becomes a queryable entity. `idColumn` config for stable per-row ids; checksum-gated re-emit on file change.
|
|
492
|
+
9. **mikser-io-render-csv** — new repo. Render plugin. RFC 4180 escaping; aggregations from sidecar `findEntities()` calls; first non-text-document render plugin in the family. **Ships in streaming mode** — returns an async iterable from `render()`, validating the widened dispatcher contract end-to-end. The "1M-row CSV builds in <2s without OOM" smoke test lives in this repo.
|
|
493
|
+
10. **mikser-io-schemas** — fix lifecycle ordering (front-matter populates meta before validation fires). Add `extract: true` flag handling. Add `lookup(entity)` surface for the extract plugin.
|
|
494
|
+
11. **README rewrite** — recipe-driven Getting Started. Multiple compositions shown.
|
|
495
|
+
12. **mikser-io-example-dms, -pkm, -photos, -invoices** — new example repos. The invoices example shows the PDF → entity → CSV round-trip end-to-end.
|
|
496
|
+
|
|
497
|
+
Items 1-3 are blocking for the 9.0 release. Items 4-8 ship at 9.0 too (so users have something to install when the engine tells them a plugin moved). Items 9-10 follow but should aim for the same release window — without recipe docs, users don't know which composition to install.
|
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -71,9 +71,38 @@ registerSchema('mikser_entities', `
|
|
|
71
71
|
CREATE INDEX IF NOT EXISTS idx_mikser_entities_uri ON mikser_entities(uri);
|
|
72
72
|
`)
|
|
73
73
|
|
|
74
|
+
// Per-process dedupe of the no-filter findEntities/iterateEntities
|
|
75
|
+
// warning. Keyed by the rendering entity id (which is what gets
|
|
76
|
+
// blamed in the recorded refClosure). Once per offending site is
|
|
77
|
+
// enough — the warning is educational, not load-bearing.
|
|
78
|
+
const _warnedNullFilter = new Set()
|
|
79
|
+
|
|
74
80
|
function recordQuery(filter) {
|
|
75
81
|
const ctx = queryContext.getStore()
|
|
76
82
|
if (!ctx?.track) return
|
|
83
|
+
// Null/undefined filter records as `null` in the snapshot's
|
|
84
|
+
// refClosure, which manifest.shouldSkip and manifest.queryAffected
|
|
85
|
+
// treat as "any mutation could have affected this render."
|
|
86
|
+
// Architecturally correct, but it means an aggregate layout whose
|
|
87
|
+
// sidecar calls findEntities() with no args invalidates on every
|
|
88
|
+
// single CREATE/UPDATE/DELETE — including spurious ones (plugins
|
|
89
|
+
// re-emitting unchanged entities, etc.).
|
|
90
|
+
//
|
|
91
|
+
// Warn once per rendering entity so authors can narrow the filter.
|
|
92
|
+
// The fix is almost always to add the collection / type / format
|
|
93
|
+
// dimension the sidecar actually cares about; the JS-side .filter()
|
|
94
|
+
// chain that usually follows findEntities() is the signal that the
|
|
95
|
+
// filter belongs in the SQL.
|
|
96
|
+
if ((filter === undefined || filter === null) && ctx.entityId) {
|
|
97
|
+
if (!_warnedNullFilter.has(ctx.entityId)) {
|
|
98
|
+
_warnedNullFilter.add(ctx.entityId)
|
|
99
|
+
const logger = useLogger()
|
|
100
|
+
logger?.warn(
|
|
101
|
+
'findEntities()/iterateEntities() called with no filter from %s — recorded query dep invalidates on every mutation. For "all renderable entities" use findEntities({"meta.href": {$exists: true}}). For narrower scopes use any indexed column ({collection, type, format, "meta.layout", "meta.lang"}); pushing the filter into SQL keeps invalidation precise.',
|
|
102
|
+
ctx.entityId,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
77
106
|
ctx.track.query(normalizeFilter(filter))
|
|
78
107
|
}
|
|
79
108
|
|
package/src/database/index.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// version, open() throws with a clear "run mikser --clear" message.
|
|
26
26
|
|
|
27
27
|
import path from 'node:path'
|
|
28
|
-
import { mkdirSync } from 'node:fs'
|
|
28
|
+
import { mkdirSync, unlinkSync } from 'node:fs'
|
|
29
29
|
import Database from 'better-sqlite3'
|
|
30
30
|
import runtime from '../runtime.js'
|
|
31
31
|
import { onLoaded } from '../lifecycle.js'
|
|
@@ -140,30 +140,52 @@ export function createSqliteDatabase({
|
|
|
140
140
|
mkdirSync(path.dirname(dbPath), { recursive: true })
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
const setupConnection = () => {
|
|
144
|
+
handle.exec('PRAGMA journal_mode = WAL')
|
|
145
|
+
handle.exec('PRAGMA synchronous = NORMAL')
|
|
146
|
+
handle.exec('PRAGMA foreign_keys = ON')
|
|
147
|
+
handle.exec(`
|
|
148
|
+
CREATE TABLE IF NOT EXISTS mikser_meta (
|
|
149
|
+
key TEXT PRIMARY KEY,
|
|
150
|
+
value TEXT NOT NULL
|
|
151
|
+
)
|
|
152
|
+
`)
|
|
153
|
+
}
|
|
148
154
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
handle.exec(`
|
|
152
|
-
CREATE TABLE IF NOT EXISTS mikser_meta (
|
|
153
|
-
key TEXT PRIMARY KEY,
|
|
154
|
-
value TEXT NOT NULL
|
|
155
|
-
)
|
|
156
|
-
`)
|
|
155
|
+
handle = new Database(dbPath)
|
|
156
|
+
setupConnection()
|
|
157
157
|
|
|
158
158
|
const recorded = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
|
|
159
159
|
.get('schema_version')?.value
|
|
160
160
|
if (recorded && recorded !== version) {
|
|
161
|
+
// Schema mismatch on upgrade or downgrade. Per ADR-0002 the
|
|
162
|
+
// files on disk are the source of truth and this database
|
|
163
|
+
// is a derived cache, so the right behavior is to wipe the
|
|
164
|
+
// cache and let the next cycle rebuild it from source —
|
|
165
|
+
// not to halt the build with an error.
|
|
166
|
+
//
|
|
167
|
+
// Loud warning so operators can see it happened and know to
|
|
168
|
+
// expect a cold-start rebuild on this run. No data loss
|
|
169
|
+
// beyond the cache itself; everything in mikser.sqlite is
|
|
170
|
+
// recoverable from the working folder.
|
|
171
|
+
logger?.warn(
|
|
172
|
+
'Database schema mismatch: stored=%s, current=%s. Wiping the cache and rebuilding from sources (files are the source of truth — no source data is affected).',
|
|
173
|
+
recorded, version,
|
|
174
|
+
)
|
|
161
175
|
handle.close()
|
|
162
176
|
handle = null
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
177
|
+
|
|
178
|
+
if (dbPath !== ':memory:') {
|
|
179
|
+
// sqlite WAL leaves -wal and -shm sidecar files. Remove
|
|
180
|
+
// them along with the main file so the next open starts
|
|
181
|
+
// from a guaranteed-clean slate.
|
|
182
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
183
|
+
try { unlinkSync(dbPath + suffix) } catch { /* file may not exist */ }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
handle = new Database(dbPath)
|
|
188
|
+
setupConnection()
|
|
167
189
|
}
|
|
168
190
|
handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
|
|
169
191
|
.run('schema_version', version)
|
package/src/engine.js
CHANGED
|
@@ -410,7 +410,20 @@ export async function setup(options) {
|
|
|
410
410
|
sidecarQueries: context?.sidecarQueries,
|
|
411
411
|
})
|
|
412
412
|
entry.deps = edges
|
|
413
|
-
|
|
413
|
+
// Pagination produces synthetic pageEntities
|
|
414
|
+
// (index.2.html, index.3.html, ...) whose ids
|
|
415
|
+
// are NOT in mikser_entities — they exist only
|
|
416
|
+
// at render time. Roll their dynamic refs up to
|
|
417
|
+
// entity.parent (set by layouts.onBeforeRender
|
|
418
|
+
// for pages 2+) so the mikser_refs FK to
|
|
419
|
+
// mikser_entities holds. The parent's own
|
|
420
|
+
// render also writes to the same source_id;
|
|
421
|
+
// INSERT OR IGNORE in stmtInsertEdge handles the
|
|
422
|
+
// dedup across pages. Invalidation re-dispatches
|
|
423
|
+
// the parent and the pagination expansion
|
|
424
|
+
// produces the children from there, so granular
|
|
425
|
+
// per-page refs aren't needed.
|
|
426
|
+
runtime.refs?.replaceDynamic(entity.parent ?? entity.id, edges)
|
|
414
427
|
await runtime.complete(entry)
|
|
415
428
|
await updateEntry({ id, output: entry.output, deps: edges })
|
|
416
429
|
}
|
package/src/plugins/files.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { mkdir, symlink, unlink, lstat, realpath } from 'fs/promises'
|
|
3
3
|
import { globby } from 'globby'
|
|
4
|
+
import pMap from 'p-map'
|
|
5
|
+
import { checksumsByCollection } from '../catalog.js'
|
|
4
6
|
|
|
5
7
|
export default ({
|
|
6
8
|
runtime,
|
|
@@ -125,25 +127,44 @@ export default ({
|
|
|
125
127
|
|
|
126
128
|
const paths = await globby('**/*', { cwd: runtime.options.filesFolder })
|
|
127
129
|
trackProgress('Files import', paths.length)
|
|
128
|
-
|
|
130
|
+
// Bulk-prefetch the catalog's existing (id → checksum) map for
|
|
131
|
+
// this collection once at scan start, so the gate below reads
|
|
132
|
+
// from it per-file instead of doing per-file SQL lookups. Same
|
|
133
|
+
// pattern source.js uses for documents/layouts via useSource.
|
|
134
|
+
// Without this gate the plugin re-emitted createEntity on every
|
|
135
|
+
// cycle for every file regardless of changes, inflating the
|
|
136
|
+
// journal with phantom mutations and triggering downstream
|
|
137
|
+
// re-dispatch of aggregate layouts whose recorded query deps
|
|
138
|
+
// matched the collection.
|
|
139
|
+
const priorChecksums = checksumsByCollection(collection)
|
|
140
|
+
await pMap(paths, async relativePath => {
|
|
129
141
|
const { uri, source } = await ensureLink(relativePath)
|
|
130
142
|
let name = relativePath
|
|
131
143
|
if (runtime.config.files?.outputFolder) {
|
|
132
144
|
name = path.join(runtime.config.files.outputFolder, relativePath)
|
|
133
145
|
}
|
|
146
|
+
const id = path.join(`/${collection}`, relativePath)
|
|
147
|
+
const newChecksum = await checksum(source)
|
|
148
|
+
updateProgress()
|
|
149
|
+
// Gate: if the catalog already has this entity with the same
|
|
150
|
+
// checksum, the file hasn't changed since the last cycle.
|
|
151
|
+
// Skip emitting a CREATE — the catalog row stays correct,
|
|
152
|
+
// the journal stays accurate (mutations = actual changes),
|
|
153
|
+
// and downstream aggregate-layout invalidation isn't fired
|
|
154
|
+
// spuriously.
|
|
155
|
+
if (priorChecksums.get(id) === newChecksum) return
|
|
134
156
|
await createEntity({
|
|
135
|
-
id
|
|
157
|
+
id,
|
|
136
158
|
uri,
|
|
137
159
|
collection,
|
|
138
160
|
type,
|
|
139
161
|
format: path.extname(relativePath).substring(1).toLowerCase(),
|
|
140
162
|
name,
|
|
141
163
|
source,
|
|
142
|
-
checksum:
|
|
143
|
-
link: await link(source)
|
|
164
|
+
checksum: newChecksum,
|
|
165
|
+
link: await link(source),
|
|
144
166
|
})
|
|
145
|
-
|
|
146
|
-
}))
|
|
167
|
+
}, { concurrency: 16 })
|
|
147
168
|
})
|
|
148
169
|
|
|
149
170
|
return {
|