mikser-io 8.3.0 → 8.3.2
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/README.md +2 -2
- package/package.json +1 -1
- package/src/engine.js +35 -13
- package/src/lifecycle.js +15 -3
- package/src/manifest.js +63 -0
- package/src/plugins/layouts.js +24 -3
- package/src/source.js +6 -1
package/README.md
CHANGED
|
@@ -208,8 +208,8 @@ For a working starter — config with a real plugin set, sample `documents/`, ex
|
|
|
208
208
|
The shape mikser fits cleanly:
|
|
209
209
|
|
|
210
210
|
- **Marketing sites with editorial teams** — content authors work in files (via their editor, a Git client, or `mikser-io-decap`), engineers ship features without negotiating with a CMS schema, the site stays portable.
|
|
211
|
-
- **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern
|
|
212
|
-
- **Content-heavy product catalogues** — `documents` + `mikser-io-schemas` + `data` plugin + a
|
|
211
|
+
- **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern decouples logical references from per-locale URLs. One source tree, many language deployments.
|
|
212
|
+
- **Content-heavy product catalogues** — `documents` + `mikser-io-schemas` + `data` plugin + a Frontend Framework = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
|
|
213
213
|
- **AI-augmented media pipelines** — `assets` plugin presets call out to Replicate / OpenAI / local models to upscale images, transcribe audio, transcode video. The pipeline is JS code, so anything Node can do is in scope.
|
|
214
214
|
- **Mixed-output publishing** — the same source document renders to HTML, PDF (via `post-pdf`), MJML email (via `post-mjml`), and JSON snapshots. One catalog, many output formats, all concurrent.
|
|
215
215
|
- **Headless backends for static frontends** — pair the `api` plugin with `sdk-api` for SSE-driven live frontends; pair the `data` plugin output with any static host for pre-rendered consumption.
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -9,7 +9,7 @@ import { onInitialize, onInitialized, onRender, onCancel, onCancelled, onFinaliz
|
|
|
9
9
|
import { useJournal, updateEntry } from './journal.js'
|
|
10
10
|
import { globby } from 'globby'
|
|
11
11
|
import { OPERATION, TASKS } from './constants.js'
|
|
12
|
-
import { changeExtension, formatErrorContext, projectMeta } from './utils.js'
|
|
12
|
+
import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
|
|
13
13
|
import render from './render.js'
|
|
14
14
|
import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
|
|
15
15
|
import map from 'p-map'
|
|
@@ -221,8 +221,16 @@ export async function setup(options) {
|
|
|
221
221
|
// cycle's CREATE/UPDATE/DELETE journal entries — RENDER entries
|
|
222
222
|
// are this cycle's work, not the trigger.
|
|
223
223
|
//
|
|
224
|
-
// - `mutatedRefs` is a Set
|
|
225
|
-
//
|
|
224
|
+
// - `mutatedRefs` is a Map<key, Set<lang|null>>: the keys are
|
|
225
|
+
// the ids / hrefs / id-minus-extension forms a refClosure
|
|
226
|
+
// entry might target; the values are the set of languages
|
|
227
|
+
// that touched that key this cycle (null for entities with
|
|
228
|
+
// no meta.lang). The Map shape preserves the fast `.has(key)`
|
|
229
|
+
// membership check the existing skip logic relied on AND
|
|
230
|
+
// adds language information so multilingual sites don't
|
|
231
|
+
// over-invalidate. When a French author entity changes,
|
|
232
|
+
// English posts that reference the same /authors/<name>
|
|
233
|
+
// href don't re-render — the lang sets disagree.
|
|
226
234
|
// - `currentHashes` carries the current input hash for each
|
|
227
235
|
// mutated entity. Cold-start file discovery emits CREATE for
|
|
228
236
|
// every file even when content didn't change; without the
|
|
@@ -231,26 +239,40 @@ export async function setup(options) {
|
|
|
231
239
|
// - `mutatedEntities` carries the entity payloads themselves so
|
|
232
240
|
// the query-match check can call `sift(filter)` against each
|
|
233
241
|
// mutation to decide whether a stored query dep is hit.
|
|
234
|
-
const mutatedRefs = new
|
|
242
|
+
const mutatedRefs = new Map()
|
|
235
243
|
const currentHashes = new Map()
|
|
236
244
|
const mutatedEntities = new Map()
|
|
237
245
|
for await (let { entity, operation } of useJournal('Manifest mutations', [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE])) {
|
|
238
246
|
if (!entity?.id) continue
|
|
239
|
-
mutatedRefs.add(entity.id)
|
|
240
|
-
if (entity.meta?.href) mutatedRefs.add(entity.meta.href)
|
|
241
247
|
mutatedEntities.set(entity.id, entity)
|
|
248
|
+
const hash = operation === OPERATION.DELETE ? null : inputHashOf(entity)
|
|
249
|
+
const lang = entity.meta?.lang ?? null
|
|
250
|
+
// Expand the mutated entity into every form a refClosure
|
|
251
|
+
// entry might target — id, meta.href, AND id-minus-extension
|
|
252
|
+
// — via lookupKeys. Without the stripped form, a refClosure
|
|
253
|
+
// recorded against the natural author/blog-post pattern
|
|
254
|
+
// (`$author: /documents/authors/dick`) would never match
|
|
255
|
+
// the mutated `/documents/authors/dick.yml` here and
|
|
256
|
+
// manifest.shouldSkip would silently return true, pinning
|
|
257
|
+
// the post's output to bytes that reference stale author
|
|
258
|
+
// data. refs.inverseClosureOf and catalog.findEntity both
|
|
259
|
+
// use the same extension-tolerant resolution; the manifest
|
|
260
|
+
// layer has to match.
|
|
261
|
+
//
|
|
262
|
+
// Each key carries the language tag of the mutation so
|
|
263
|
+
// shouldSkip can constrain by language compatibility.
|
|
264
|
+
//
|
|
242
265
|
// For DELETE we set `null` as the current hash so manifest.
|
|
243
266
|
// shouldSkip can distinguish "target was deleted from the
|
|
244
267
|
// catalog" from "target wasn't in this cycle's mutations
|
|
245
268
|
// at all." Without this distinction, a consumer whose
|
|
246
269
|
// refClosure points at a deleted partial/layout would
|
|
247
|
-
// silently skip re-rendering
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
)
|
|
270
|
+
// silently skip re-rendering.
|
|
271
|
+
for (const key of lookupKeys(entity)) {
|
|
272
|
+
if (!mutatedRefs.has(key)) mutatedRefs.set(key, new Set())
|
|
273
|
+
mutatedRefs.get(key).add(lang)
|
|
274
|
+
currentHashes.set(key, hash)
|
|
275
|
+
}
|
|
254
276
|
}
|
|
255
277
|
let skipped = 0
|
|
256
278
|
|
package/src/lifecycle.js
CHANGED
|
@@ -14,11 +14,23 @@ export async function createEntity(entity) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// deleteEntity accepts either a minimal { id, type, collection }
|
|
18
|
+
// payload or a full entity. The full-entity form is preferred when
|
|
19
|
+
// the caller has it on hand (source.sweepDeleted does — it just
|
|
20
|
+
// findById'd the entity to compute the deletion), because the
|
|
21
|
+
// manifest's query-affected dispatch (and any downstream sift-based
|
|
22
|
+
// invalidation) can only match against the entity's actual fields.
|
|
23
|
+
// A minimal {id,type,collection} still works for backwards
|
|
24
|
+
// compatibility but query deps on fields not in that triple
|
|
25
|
+
// (format, meta.*, name, etc.) won't fire.
|
|
26
|
+
export async function deleteEntity(payload) {
|
|
18
27
|
const logger = useLogger()
|
|
19
|
-
const
|
|
28
|
+
const entity = payload?.id
|
|
29
|
+
? payload // accept full entity payload as-is
|
|
30
|
+
: { id: payload?.id, type: payload?.type, collection: payload?.collection }
|
|
31
|
+
const entry = { operation: OPERATION.DELETE, entity }
|
|
20
32
|
if (await runtime.validate(entry)) {
|
|
21
|
-
logger.debug('Delete %s entity: %s %s', collection, type, id)
|
|
33
|
+
logger.debug('Delete %s entity: %s %s', entity.collection, entity.type, entity.id)
|
|
22
34
|
await addEntry(entry)
|
|
23
35
|
}
|
|
24
36
|
}
|
package/src/manifest.js
CHANGED
|
@@ -232,6 +232,15 @@ export function createManifest(db) {
|
|
|
232
232
|
AND json_extract(value, '$.hash') IS NOT NULL
|
|
233
233
|
GROUP BY json_extract(value, '$.target')
|
|
234
234
|
`)
|
|
235
|
+
// Snapshots whose refClosure contains at least one query dep. The
|
|
236
|
+
// LIKE pre-filter is cheap (JSON column scan with no parse) and
|
|
237
|
+
// narrows down to the small set of aggregate-layout renders.
|
|
238
|
+
// queryAffected then JSON.parses just those rows and sift-matches
|
|
239
|
+
// each query filter against each mutated entity.
|
|
240
|
+
const stmtSnapshotsWithQuery = db.prepare(`
|
|
241
|
+
SELECT id, refClosure FROM mikser_snapshots
|
|
242
|
+
WHERE refClosure LIKE '%"kind":"query"%'
|
|
243
|
+
`)
|
|
235
244
|
|
|
236
245
|
const manifest = {
|
|
237
246
|
// Look up a previously-recorded entry by entity (or by an
|
|
@@ -253,6 +262,7 @@ export function createManifest(db) {
|
|
|
253
262
|
if (!snapshot?.inputHash) return false
|
|
254
263
|
if (inputHashOf(entity) !== snapshot.inputHash) return false
|
|
255
264
|
if (!snapshot.refClosure?.length) return true
|
|
265
|
+
const sourceLang = entity?.meta?.lang ?? null
|
|
256
266
|
for (const entry of snapshot.refClosure) {
|
|
257
267
|
if (entry.kind === 'query') {
|
|
258
268
|
if (!entry.filter) return false
|
|
@@ -264,6 +274,20 @@ export function createManifest(db) {
|
|
|
264
274
|
continue
|
|
265
275
|
}
|
|
266
276
|
if (!mutatedRefs?.has(entry.target)) continue
|
|
277
|
+
// Language scope: if the source has a meta.lang AND the
|
|
278
|
+
// mutation came from a different meta.lang, the ref
|
|
279
|
+
// doesn't actually depend on what changed. A `null` lang
|
|
280
|
+
// in the mutation set means a shared (un-localized)
|
|
281
|
+
// entity touched the same key — that does invalidate
|
|
282
|
+
// language-specific sources because the shared entity
|
|
283
|
+
// is the only variant. Sources without a meta.lang are
|
|
284
|
+
// language-agnostic and accept any mutation lang.
|
|
285
|
+
if (sourceLang) {
|
|
286
|
+
const mutatedLangs = mutatedRefs.get(entry.target)
|
|
287
|
+
if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
}
|
|
267
291
|
if (!entry.hash) return false
|
|
268
292
|
const currentHash = currentHashes?.get(entry.target)
|
|
269
293
|
if (currentHash === undefined) continue
|
|
@@ -278,6 +302,45 @@ export function createManifest(db) {
|
|
|
278
302
|
stmtUpsert.run(snapToRow(buildSnapshot(entity, deps)))
|
|
279
303
|
},
|
|
280
304
|
|
|
305
|
+
// Return the set of entity ids whose recorded snapshots have a
|
|
306
|
+
// query dep that matches any of the cycle's mutated entities.
|
|
307
|
+
// The static-ref closure walk (refs.inverseClosureOf) only finds
|
|
308
|
+
// entities reachable via $-keyed edges in mikser_refs; aggregate
|
|
309
|
+
// layouts that depend on findEntities(...) results need this
|
|
310
|
+
// second-pass dispatch hint.
|
|
311
|
+
//
|
|
312
|
+
// Cheap when there are no aggregate layouts (LIKE pre-filter
|
|
313
|
+
// returns no rows). Cost scales with (snapshots-with-query) ×
|
|
314
|
+
// (mutated-entities), both typically small.
|
|
315
|
+
queryAffected(mutatedEntities) {
|
|
316
|
+
const affected = new Set()
|
|
317
|
+
if (!mutatedEntities?.size) return affected
|
|
318
|
+
for (const row of stmtSnapshotsWithQuery.iterate()) {
|
|
319
|
+
const refClosure = row.refClosure ? JSON.parse(row.refClosure) : []
|
|
320
|
+
let hit = false
|
|
321
|
+
for (const entry of refClosure) {
|
|
322
|
+
if (entry.kind !== 'query') continue
|
|
323
|
+
if (!entry.filter) {
|
|
324
|
+
// Null filter = unserializable predicate captured
|
|
325
|
+
// at render time. Conservative: invalidate on any
|
|
326
|
+
// mutation.
|
|
327
|
+
hit = true
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
const matcher = sift(entry.filter)
|
|
331
|
+
for (const mutated of mutatedEntities.values()) {
|
|
332
|
+
if (matcher(mutated)) {
|
|
333
|
+
hit = true
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (hit) break
|
|
338
|
+
}
|
|
339
|
+
if (hit) affected.add(row.id)
|
|
340
|
+
}
|
|
341
|
+
return affected
|
|
342
|
+
},
|
|
343
|
+
|
|
281
344
|
// Drop all snapshots owned by entity id (direct outputs and any
|
|
282
345
|
// paginated children whose `parent` is set to this id). Returns
|
|
283
346
|
// the destinations that were removed so callers can unlink the
|
package/src/plugins/layouts.js
CHANGED
|
@@ -565,12 +565,24 @@ export default ({
|
|
|
565
565
|
// typical site has 0-10 of these, no full scan.
|
|
566
566
|
const optOutEntities = await findEntities({ 'meta.cache': 0 })
|
|
567
567
|
|
|
568
|
-
|
|
568
|
+
// Query-dep affected snapshots: aggregate layouts that
|
|
569
|
+
// depend on findEntities(...) instead of static $-refs need
|
|
570
|
+
// a second-pass dispatch hint. manifest.queryAffected walks
|
|
571
|
+
// every snapshot whose refClosure contains a `query` entry
|
|
572
|
+
// and sift-matches the recorded filter against the cycle's
|
|
573
|
+
// mutated entities. Bounded by snapshots-with-query (small
|
|
574
|
+
// — index pages, sitemaps, RSS) × seeds.
|
|
575
|
+
const mutatedEntities = new Map(seeds.map(s => [s.id, s]))
|
|
576
|
+
const queryAffected = runtime.manifest?.queryAffected(mutatedEntities) ?? new Set()
|
|
577
|
+
|
|
578
|
+
if (seeds.length === 0 && optOutEntities.length === 0 && queryAffected.size === 0) return
|
|
569
579
|
|
|
570
580
|
const closure = seeds.length ? runtime.refs.inverseClosureOf(seeds) : new Set()
|
|
571
|
-
// Combine closure ids + opt-out ids
|
|
581
|
+
// Combine closure ids + opt-out ids + query-affected ids
|
|
582
|
+
// into one dispatch set.
|
|
572
583
|
const dispatchIds = new Set(closure)
|
|
573
584
|
for (const e of optOutEntities) dispatchIds.add(e.id)
|
|
585
|
+
for (const id of queryAffected) dispatchIds.add(id)
|
|
574
586
|
|
|
575
587
|
// Hydrate each id via findById. LRU cache absorbs
|
|
576
588
|
// duplicates (refs BFS revisits, partial dispatches
|
|
@@ -700,7 +712,16 @@ export default ({
|
|
|
700
712
|
postprocessor: entity.layout.postprocessor,
|
|
701
713
|
tasks: entity.meta?.task || TASKS.INLINE
|
|
702
714
|
},
|
|
703
|
-
|
|
715
|
+
// sidecarQueries threads the sidecar load()'s
|
|
716
|
+
// findEntities calls into manifest.collectEdges
|
|
717
|
+
// as `{kind: 'query', filter}` refClosure entries.
|
|
718
|
+
// Without it, aggregate layouts that don't
|
|
719
|
+
// paginate (sitemap.xml, index pages, RSS feeds)
|
|
720
|
+
// lose query-dep tracking and never invalidate
|
|
721
|
+
// when matching entities are added/modified/
|
|
722
|
+
// deleted. The paginated branch above already
|
|
723
|
+
// does this.
|
|
724
|
+
context: { data, plugins, sidecarQueries: sidecarTrack.queries }
|
|
704
725
|
})
|
|
705
726
|
}
|
|
706
727
|
}
|
package/src/source.js
CHANGED
|
@@ -351,7 +351,12 @@ export function useSource(core, options) {
|
|
|
351
351
|
}, { concurrency: SCAN_CONCURRENCY })
|
|
352
352
|
|
|
353
353
|
scanStats.deleted = await sweepDeleted(collection, scanned, async (e) => {
|
|
354
|
-
|
|
354
|
+
// Pass the full entity (e is hydrated from catalog by
|
|
355
|
+
// sweepDeleted) so the manifest's query-affected dispatch
|
|
356
|
+
// can sift-match aggregate-layout filters against the
|
|
357
|
+
// actual fields (format, meta.layout, etc.). Minimal
|
|
358
|
+
// {id,type,collection} would miss those filters.
|
|
359
|
+
await deleteEntity(e)
|
|
355
360
|
logger.debug('%s removed (file gone): %s', collection, e.name)
|
|
356
361
|
})
|
|
357
362
|
|