mikser-io 8.3.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "8.3.1",
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/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
@@ -293,6 +302,45 @@ export function createManifest(db) {
293
302
  stmtUpsert.run(snapToRow(buildSnapshot(entity, deps)))
294
303
  },
295
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
+
296
344
  // Drop all snapshots owned by entity id (direct outputs and any
297
345
  // paginated children whose `parent` is set to this id). Returns
298
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