mikser-io 8.3.1 → 8.3.6

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.6",
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/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/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
- runtime.refs?.replaceDynamic(entity.id, edges)
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/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
@@ -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
- return Promise.all(paths.map(async relativePath => {
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: path.join(`/${collection}`, relativePath),
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: await checksum(source),
143
- link: await link(source)
164
+ checksum: newChecksum,
165
+ link: await link(source),
144
166
  })
145
- updateProgress()
146
- }))
167
+ }, { concurrency: 16 })
147
168
  })
148
169
 
149
170
  return {
@@ -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