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 +1 -1
- package/src/catalog.js +29 -0
- package/src/engine.js +14 -1
- package/src/lifecycle.js +15 -3
- package/src/manifest.js +48 -0
- package/src/plugins/files.js +27 -6
- package/src/plugins/layouts.js +24 -3
- package/src/source.js +6 -1
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/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/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
|
|
@@ -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
|
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 {
|
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
|
|