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 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 in `sdk-vue` 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 Vue frontend = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "8.3.0",
3
+ "version": "8.3.2",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
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 of ids (and hrefs) — fast membership
225
- // check for layout/partial/$-ref edges.
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 Set()
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 — leaving the disk output
248
- // pinned to bytes that reference something no longer in
249
- // the catalog.
250
- currentHashes.set(
251
- entity.id,
252
- operation === OPERATION.DELETE ? null : inputHashOf(entity),
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
- export async function deleteEntity({ id, collection, type }) {
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 entry = { operation: OPERATION.DELETE, entity: { id, type, collection } }
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
@@ -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
- if (seeds.length === 0 && optOutEntities.length === 0) return
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 into one dispatch set.
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
- context: { data, plugins }
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
- await deleteEntity({ id: e.id, type, collection })
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