mikser-io 9.20.0 → 9.21.1

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.
@@ -35,10 +35,29 @@ npx mikser --explain /documents/en/posts/hello.md
35
35
  ```
36
36
 
37
37
  It prints the entity's layout and **why that layout matched**, its
38
- destination, its `inputHash`, whether the file on disk still agrees with
39
- the catalog, every recorded render with its `refClosure`, and a verdict
40
- in plain words — `would be SKIPPED — input hash unchanged`, or `would
41
- re-render`, or `source file is gone`.
38
+ destination, its `inputHash` and the components that went into it,
39
+ whether the file on disk still agrees with the catalog, every recorded
40
+ render with its `refClosure`, and a verdict in plain words.
41
+
42
+ When an entity's inputs have moved since a render, the verdict names what
43
+ moved rather than only that something did:
44
+
45
+ ```
46
+ would re-render — meta.title changed since it was last rendered
47
+ ```
48
+
49
+ and the render line carries the same detail per snapshot:
50
+
51
+ ```
52
+ rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved since]
53
+ moved content, meta.weight (added)
54
+ ```
55
+
56
+ A snapshot written before per-input recording says so rather than
57
+ guessing. Note that `--explain` compares the CATALOG's entity against the
58
+ snapshot: if you have edited a file and not yet built, the verdict is
59
+ `source differs from the catalog` — the edit has not been imported yet, so
60
+ there is nothing to attribute. Build, then ask.
42
61
 
43
62
  Each `refClosure` edge shows the name that was asked for and the entity
44
63
  it bound to, and flags the ones that bound to nothing:
@@ -77,6 +96,27 @@ Four buckets, and the distinction between them is the point:
77
96
  `never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
78
97
  `cache-disabled`, `postprocessor`, `force`, `no-manifest`.
79
98
 
99
+ `inputs-changed` carries a `changed` array naming **which** input moved,
100
+ so you do not have to go to the database to find out:
101
+
102
+ ```json
103
+ { "id": "/files/hero.jpg", "reason": "inputs-changed", "changed": ["checksum"] }
104
+ { "id": "/documents/page.md", "reason": "inputs-changed", "changed": ["meta.title"] }
105
+ { "id": "/documents/page.md", "reason": "inputs-changed", "changed": ["content"] }
106
+ { "id": "/layouts/post.hbs", "reason": "inputs-changed", "changed": ["inputs.shared"] }
107
+ ```
108
+
109
+ `checksum` means the bytes on disk moved; `content` means the body did;
110
+ `meta.<field>` names the front-matter field; `inputs.<key>` is a declared
111
+ input such as a layout's sidecar digest. A field that appeared or vanished
112
+ reads as `meta.weight (added)` / `(removed)`, which is the answer when a
113
+ document gains or loses front-matter.
114
+
115
+ The array is absent on a first render — there is no prior snapshot to
116
+ compare against — and on `ref-changed`, because that is a dependency
117
+ moving rather than the entity's own inputs. Conflating the two would make
118
+ the attribution misleading.
119
+
80
120
  `unchanged` is the interesting one. It means invalidation was coarser
81
121
  than it needed to be — the render was scheduled, ran, and produced
82
122
  nothing new. A high count is not a bug, but it tells you where the
package/index.js CHANGED
@@ -50,11 +50,11 @@ export { renderHbs } from './src/plugins/render/hbs.js'
50
50
  export { renderPreset } from './src/plugins/render/preset.js'
51
51
 
52
52
  // Template helpers — these only install functions on `runtime` for templates
53
- // to call. They render nothing, which is what the old render* names hid: two
54
- // of the six factories in this folder are renderers and four are not, and
55
- // naming all six after the object they concern rather than the job they do
56
- // led someone to add renderPreset() expecting a helper and watch every page
57
- // render throw. Renamed in 10.0.0; there are no aliases.
53
+ // to call. They render nothing, and the names say so: two of the six
54
+ // factories in this folder are renderers and four are not, so each is named
55
+ // after the job it does rather than the object it concerns. Naming them all
56
+ // `render*` invites adding renderPreset() expecting a helper and watching
57
+ // every page render throw. There are no aliases for the older names.
58
58
  export { assetUrlHelper } from './src/plugins/render/asset.js'
59
59
  export { hrefUrlHelpers } from './src/plugins/render/href.js'
60
60
  export { resourceUrlHelper } from './src/plugins/render/resource.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.20.0",
3
+ "version": "9.21.1",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/catalog.js CHANGED
@@ -360,8 +360,10 @@ onFinalize(async () => {
360
360
  }
361
361
  })
362
362
 
363
- // Snapshot the catalog as `{ version, entities: [...] }`. Same shape
364
- // the NDJSON test harness used to read off disk. O(N) — debug only.
363
+ // Snapshot the catalog as `{ version, entities: [...] }` — the schema
364
+ // version plus every entity body. O(N) in both time and memory, so it is
365
+ // for debugging and inspection, never a hot path; `iterateEntities`
366
+ // streams when the result set might be corpus-scale.
365
367
  function exportCatalog() {
366
368
  if (!db?.isOpen) return { version: null, entities: [] }
367
369
  const rows = stmtAllData.all()
package/src/config.js CHANGED
@@ -56,11 +56,9 @@ onLoad(async () => {
56
56
  }
57
57
  }
58
58
 
59
- // v8 used to walk `runtime.config.plugins` looking for matching
60
- // `config/<plugin>.config.js` files to merge into `runtime.config`,
61
- // because plugin entries were strings (names). v9 entries are factory
62
- // call results (closures or descriptors), and plugin options arrive
63
- // as factory args — see ADR-0010 — so per-plugin auxiliary config
64
- // files have nothing to bind to. The loader was removed when the
65
- // plugins list stopped carrying names.
59
+ // Nothing else is loaded. There is deliberately no `config/<plugin>
60
+ // .config.js` channel: plugin options arrive as factory arguments
61
+ // (ADR-0010), and an entry in `plugins` is a factory call result — a
62
+ // closure or a descriptor — carrying no name to bind an auxiliary file
63
+ // to. A plugin wanting file-based config reads it itself.
66
64
  })
package/src/engine.js CHANGED
@@ -374,7 +374,7 @@ export async function setup(options) {
374
374
  logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
375
375
  return
376
376
  }
377
- reportRendered(entity, decision.reason)
377
+ reportRendered(entity, decision.reason, decision.changed)
378
378
  // Project reference-marker keys (`$author`, `$hero`, …)
379
379
  // into their normalized form (`author`, `hero`) before
380
380
  // the entity crosses into the renderer — applies whether
package/src/explain.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // needs knowledge a user of the tool should not need.
9
9
  //
10
10
  // Follows --verify's shape: report and exit, no build phases run.
11
- import { inputHashOf, lookupKeys, checksum as fileChecksum } from './utils.js'
11
+ import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
12
12
  import { findEntity } from './catalog.js'
13
13
  import runtime from './runtime.js'
14
14
 
@@ -28,6 +28,22 @@ async function resolve(reference) {
28
28
  return like ?? null
29
29
  }
30
30
 
31
+ // The verdict line names what moved when it can. That line is the one
32
+ // people read, so "the input hash differs" there is the answer stopping one
33
+ // step short of useful.
34
+ function renderVerdict(snapshots, currentHash, currentParts) {
35
+ const moved = new Set()
36
+ for (const snap of snapshots) {
37
+ if (snap.inputHash === currentHash || !snap.inputParts) continue
38
+ const d = diffInputParts(snap.inputParts, currentParts)
39
+ for (const key of [...d.changed, ...d.added, ...d.removed]) moved.add(key)
40
+ }
41
+ if (!moved.size) {
42
+ return 'would re-render — the entity\'s input hash differs from what it was last rendered at'
43
+ }
44
+ return `would re-render — ${[...moved].join(', ')} changed since it was last rendered`
45
+ }
46
+
31
47
  export async function explain(reference) {
32
48
  const entity = await resolve(reference)
33
49
  if (!entity) {
@@ -43,6 +59,7 @@ export async function explain(reference) {
43
59
 
44
60
  const snapshots = runtime.manifest?.snapshotsFor(entity.id) ?? []
45
61
  const currentHash = inputHashOf(entity)
62
+ const currentParts = inputPartsOf(entity)
46
63
 
47
64
  // The catalog is as of the LAST BUILD. If the file has been edited since,
48
65
  // nothing here knows it yet — the hashes would all agree and the verdict
@@ -96,9 +113,11 @@ export async function explain(reference) {
96
113
  // current hash; each snapshot carries the hash it was rendered at, so
97
114
  // the two disagreeing IS the answer to "why did this change".
98
115
  inputHash: currentHash,
99
- inputHashOf: entity.checksum && entity.meta == null && entity.content == null
100
- ? 'checksum'
101
- : 'meta+content+inputs',
116
+ // The components that actually went into the hash for THIS entity,
117
+ // read off the parts rather than restated — a hardcoded label drifts
118
+ // the moment the payload changes, and this one had.
119
+ inputHashOf: [...new Set(Object.keys(currentParts).map(k => k.split('.')[0]))].join('+')
120
+ || 'nothing',
102
121
  inputs: entity.inputs ?? null,
103
122
  checksum: entity.checksum ?? null,
104
123
  source,
@@ -109,6 +128,15 @@ export async function explain(reference) {
109
128
  // The single most useful field: does this entity's current hash
110
129
  // match what it was last rendered at?
111
130
  stale: snap.inputHash !== currentHash,
131
+ // WHICH input moved, not merely that one did. This is the whole
132
+ // question behind "why did this re-render" — answering it from
133
+ // the recorded parts costs nothing, and not answering it sends
134
+ // the reader to a database query for something already here.
135
+ moved: snap.inputHash === currentHash
136
+ ? null
137
+ : snap.inputParts
138
+ ? diffInputParts(snap.inputParts, currentParts)
139
+ : 'unknown',
112
140
  outputHash: snap.outputHash ?? null,
113
141
  parent: snap.parent ?? null,
114
142
  refClosure: (snap.refClosure ?? []).map(entry =>
@@ -136,7 +164,7 @@ export async function explain(reference) {
136
164
  : snapshots.length === 0
137
165
  ? 'never rendered — no manifest snapshot. Either it has no layout, or its layout produced no destination.'
138
166
  : snapshots.some(s => s.inputHash !== currentHash)
139
- ? 'would re-render — the entity\'s input hash differs from what it was last rendered at'
167
+ ? renderVerdict(snapshots, currentHash, currentParts)
140
168
  : 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
141
169
  lookupKeys: lookupKeys(entity),
142
170
  }
package/src/manifest.js CHANGED
@@ -56,7 +56,7 @@ import { useLogger } from './engine.js'
56
56
  import { onLoaded, onFinalize } from './lifecycle.js'
57
57
  import { useJournal } from './journal.js'
58
58
  import { OPERATION } from './constants.js'
59
- import { extractRefs, inputHashOf } from './utils.js'
59
+ import { extractRefs, inputHashOf, inputPartsOf, diffInputParts } from './utils.js'
60
60
  import { filterKey } from './track.js'
61
61
  import { findById } from './catalog.js'
62
62
  import { useDatabase, registerSchema } from './database/index.js'
@@ -74,6 +74,7 @@ export const SNAPSHOTS_SCHEMA = `
74
74
  id TEXT NOT NULL,
75
75
  destination TEXT NOT NULL,
76
76
  inputHash TEXT,
77
+ inputParts TEXT,
77
78
  outputHash TEXT,
78
79
  refClosure TEXT,
79
80
  renderedAt INTEGER,
@@ -97,6 +98,20 @@ function sha1(payload) {
97
98
  }
98
99
 
99
100
  // refClosure builder — same logic as before, no DB involvement.
101
+ // Which input moved, as a flat list of part names ('content',
102
+ // 'meta.title', 'checksum', 'inputs.shared'). Empty when the snapshot
103
+ // predates part recording — the combined hash still says the entity
104
+ // changed, and saying nothing is better than guessing which part.
105
+ function describeInputChange(entity, snapshot) {
106
+ if (!snapshot?.inputParts) return []
107
+ const { changed, added, removed } = diffInputParts(snapshot.inputParts, inputPartsOf(entity))
108
+ return [
109
+ ...changed,
110
+ ...added.map(key => `${key} (added)`),
111
+ ...removed.map(key => `${key} (removed)`),
112
+ ]
113
+ }
114
+
100
115
  function buildRefClosure(entity, deps) {
101
116
  const closure = []
102
117
  const seen = new Set()
@@ -155,6 +170,11 @@ function buildSnapshot(entity, deps, outputHash) {
155
170
  id: entity.id,
156
171
  destination: entity.destination,
157
172
  inputHash: inputHashOf(entity),
173
+ // Per-component hashes of the same payload the combined hash covers,
174
+ // so a later run can name WHICH input moved instead of only that one
175
+ // did. Without it `inputs-changed` sends the reader to a database
176
+ // query for something the tool already has in hand.
177
+ inputParts: inputPartsOf(entity),
158
178
  refClosure: buildRefClosure(entity, deps),
159
179
  renderedAt: Date.now(),
160
180
  }
@@ -171,6 +191,7 @@ function rowToSnap(row) {
171
191
  id: row.id,
172
192
  destination: row.destination,
173
193
  inputHash: row.inputHash ?? undefined,
194
+ inputParts: row.inputParts ? JSON.parse(row.inputParts) : undefined,
174
195
  outputHash: row.outputHash ?? undefined,
175
196
  refClosure: row.refClosure ? JSON.parse(row.refClosure) : undefined,
176
197
  renderedAt: row.renderedAt ?? undefined,
@@ -183,6 +204,7 @@ function snapToRow(snap) {
183
204
  id: snap.id,
184
205
  destination: snap.destination,
185
206
  inputHash: snap.inputHash ?? null,
207
+ inputParts: snap.inputParts ? JSON.stringify(snap.inputParts) : null,
186
208
  outputHash: snap.outputHash ?? null,
187
209
  refClosure: snap.refClosure ? JSON.stringify(snap.refClosure) : null,
188
210
  renderedAt: snap.renderedAt ?? null,
@@ -203,10 +225,11 @@ function snapToRow(snap) {
203
225
  // is the dominant shape. A page destination treated as a filesystem path
204
226
  // would otherwise be looked for at the root of the disk.
205
227
  //
206
- // Shared, because the two callers needing it drifted into two separate
207
- // bugs: verify() reported every asset missing while printing its real
208
- // path, and hashOutputFile silently recorded no outputHash for one, which
209
- // left 78% of a real project's snapshots presence-checked only.
228
+ // One definition for both callers. verify() and hashOutputFile ask the same
229
+ // question, and a private copy of the join in each is how one mistake
230
+ // becomes two symptoms — one loud (every asset reported missing, at its
231
+ // real and present path) and one silent (no outputHash recorded, leaving
232
+ // most snapshots presence-checked only).
210
233
  export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
211
234
  if (!destination) return undefined
212
235
  const joined = path.join(outputFolder ?? '', destination)
@@ -230,18 +253,18 @@ export function createManifest(db) {
230
253
  if (!db) throw new Error('createManifest: db is required')
231
254
 
232
255
  const stmtLookupById = db.prepare(`
233
- SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
256
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
234
257
  FROM mikser_snapshots WHERE id = ? ORDER BY destination
235
258
  `)
236
259
  const stmtLookup = db.prepare(`
237
- SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
260
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
238
261
  FROM mikser_snapshots WHERE id = ? AND destination = ?
239
262
  `)
240
263
  const stmtUpsert = db.prepare(`
241
264
  INSERT OR REPLACE INTO mikser_snapshots
242
- (id, destination, inputHash, outputHash, refClosure, renderedAt, parent)
265
+ (id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent)
243
266
  VALUES
244
- (@id, @destination, @inputHash, @outputHash, @refClosure, @renderedAt, @parent)
267
+ (@id, @destination, @inputHash, @inputParts, @outputHash, @refClosure, @renderedAt, @parent)
245
268
  `)
246
269
  const stmtDeleteByPK = db.prepare(`
247
270
  DELETE FROM mikser_snapshots WHERE id = ? AND destination = ?
@@ -257,7 +280,7 @@ export function createManifest(db) {
257
280
  SELECT id, destination FROM mikser_snapshots WHERE parent = ?
258
281
  `)
259
282
  const stmtSelectAll = db.prepare(`
260
- SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
283
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
261
284
  FROM mikser_snapshots
262
285
  `)
263
286
  const stmtCount = db.prepare(`SELECT COUNT(*) AS c FROM mikser_snapshots`)
@@ -352,7 +375,16 @@ export function createManifest(db) {
352
375
  if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
353
376
  const snapshot = this.lookup(entity)
354
377
  if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
355
- if (inputHashOf(entity) !== snapshot.inputHash) return { skip: false, reason: 'inputs-changed' }
378
+ if (inputHashOf(entity) !== snapshot.inputHash) {
379
+ // Name the component that moved. "inputs-changed" alone is
380
+ // the answer to a question nobody asked — the reader wants to
381
+ // know WHICH input, and the recorded parts have it.
382
+ return {
383
+ skip: false,
384
+ reason: 'inputs-changed',
385
+ changed: describeInputChange(entity, snapshot),
386
+ }
387
+ }
356
388
  if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
357
389
  const sourceLang = entity?.meta?.lang ?? null
358
390
  for (const entry of snapshot.refClosure) {
@@ -607,8 +607,9 @@ export function api(options = {}) {
607
607
  // credential declaring `api:delete` still cannot delete on an
608
608
  // endpoint whose `operations` omits it, and a bare token (which
609
609
  // declares nothing, capabilities === null) is bounded by the
610
- // endpoint alone — which is exactly how every endpoint behaved
611
- // before this existed.
610
+ // endpoint alone, which is what makes a plain token still a
611
+ // complete answer: it grants the endpoint's own ceiling and
612
+ // nothing more.
612
613
  const allow = (op) => (req, res, next) => {
613
614
  if (!allowedOps.has(op)) {
614
615
  return res.status(403).json({
@@ -15,8 +15,8 @@ import path from 'node:path'
15
15
  // assets() runs presets and produces derivatives (the work)
16
16
  // renderPreset() renders a preset-authored layout (this file)
17
17
  //
18
- // The helpers were renamed for their role in 10.0.0 for exactly this reason;
19
- // renderPreset keeps its name because it really is a renderer.
18
+ // The URL helpers carry `Helper` in their names for exactly this reason;
19
+ // renderPreset keeps its bare name because it really is a renderer.
20
20
  export async function load({ entity, runtime }) {
21
21
  if (!entity?.preset?.uri) return
22
22
  const preset = await import(`${entity.preset.uri}?stamp=${Date.now()}`)
@@ -253,10 +253,10 @@ export default async ({ entity, options, config, context, state, logger, port })
253
253
  }
254
254
 
255
255
  // Cleanup the renderer's origin (first stage's input) when it's
256
- // a different path from the chain's final destination. The
257
- // single-stage onComplete in layouts.js used to do this; under
258
- // the chain contract the dispatcher owns it so onComplete can
259
- // stay disk-write-agnostic for postprocess outputs.
256
+ // a different path from the chain's final destination. The chain
257
+ // contract puts this on the dispatcher rather than on layouts'
258
+ // onComplete, which is what lets onComplete stay agnostic about
259
+ // whether a postprocess output was written to disk at all.
260
260
  if (entity.origin && entity.origin !== entity.destination) {
261
261
  try { await unlink(path.join(options.outputFolder, entity.origin)) } catch {}
262
262
  }
@@ -264,7 +264,7 @@ export default async ({ entity, options, config, context, state, logger, port })
264
264
  // Return undefined so the engine's onPostprocess loop doesn't
265
265
  // try to thread `result` back through layouts' onComplete (which
266
266
  // would attempt a writeFile against a non-bytes value). The
267
- // postprocess plugins wrote directly to disk; there's nothing
268
- // for the engine to flush.
267
+ // postprocess plugins have written directly to disk by this point;
268
+ // there is nothing for the engine to flush.
269
269
  return undefined
270
270
  }
package/src/report.js CHANGED
@@ -28,9 +28,16 @@ export function reportGated(count = 1) {
28
28
  store().gated += count
29
29
  }
30
30
 
31
- export function reportRendered(entity, reason) {
31
+ export function reportRendered(entity, reason, changed) {
32
32
  if (!runtime.options?.json) return
33
- store().rendered.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
33
+ store().rendered.push({
34
+ id: entity?.id,
35
+ destination: entity?.destination ?? null,
36
+ reason,
37
+ // Which input moved, when the reason is inputs-changed. Omitted
38
+ // rather than empty so a consumer can test for its presence.
39
+ ...(changed?.length ? { changed } : {}),
40
+ })
34
41
  }
35
42
 
36
43
  // A render that RAN and produced bytes identical to what was already on
package/src/utils.js CHANGED
@@ -15,9 +15,11 @@ import runtime from './runtime.js'
15
15
  // seeding. Excludes volatile fields like stamp/time/uri so re-discovery
16
16
  // on startup doesn't produce a different hash for an unchanged file.
17
17
  // Pure: synchronous, no I/O, no engine state.
18
- export function inputHashOf(entity) {
19
- if (!entity) return ''
20
- return crypto.createHash('sha1').update(JSON.stringify({
18
+ // The payload both `inputHashOf` and `inputPartsOf` describe. One
19
+ // definition, because a hash and an attribution of that hash that disagree
20
+ // about what went into it is worse than having no attribution.
21
+ function inputPayload(entity) {
22
+ return {
21
23
  meta: entity.meta ?? null,
22
24
  content: entity.content ?? null,
23
25
  // The bytes' fingerprint, for entities whose content is not in
@@ -46,7 +48,69 @@ export function inputHashOf(entity) {
46
48
  // an entity that HAS content is hashed on {meta, content} and its
47
49
  // checksum is ignored. This is the seam that was missing.
48
50
  inputs: entity.inputs ?? null,
49
- })).digest('hex')
51
+ }
52
+ }
53
+
54
+ export function inputHashOf(entity) {
55
+ if (!entity) return ''
56
+ return crypto.createHash('sha1').update(JSON.stringify(inputPayload(entity))).digest('hex')
57
+ }
58
+
59
+ // Per-component hashes of the same payload, flat and one level deep:
60
+ //
61
+ // { 'meta.title': 'ab12cd34', content: '…', 'inputs.shared': '…' }
62
+ //
63
+ // Recorded alongside the combined hash so a later run can say WHICH input
64
+ // moved rather than only that one did. `inputs-changed` on its own sends
65
+ // the reader to a database query to answer something already known here.
66
+ //
67
+ // Components that are null are omitted rather than hashed, so a component
68
+ // appearing or disappearing reads as an added or removed key — which is
69
+ // the answer in its own right when a document gains front-matter or a
70
+ // layout gains a sidecar.
71
+ //
72
+ // Depth one: naming `meta.title` is the difference between a useful answer
73
+ // and "something under meta". Deeper nesting would grow the snapshot for
74
+ // diminishing returns — the key names the field to look at, and the field
75
+ // is then in front of you.
76
+ export function inputPartsOf(entity) {
77
+ if (!entity) return {}
78
+ const parts = {}
79
+ const short = (value) => crypto.createHash('sha1')
80
+ .update(JSON.stringify(value ?? null)).digest('hex').slice(0, 8)
81
+ const payload = inputPayload(entity)
82
+ for (const [component, value] of Object.entries(payload)) {
83
+ if (value == null) continue
84
+ if (component === 'meta' || component === 'inputs') {
85
+ if (typeof value !== 'object' || Array.isArray(value)) {
86
+ parts[component] = short(value)
87
+ continue
88
+ }
89
+ for (const [key, inner] of Object.entries(value)) {
90
+ parts[`${component}.${key}`] = short(inner)
91
+ }
92
+ continue
93
+ }
94
+ parts[component] = short(value)
95
+ }
96
+ return parts
97
+ }
98
+
99
+ // What moved between two part maps. Returns the keys, split by how they
100
+ // differ, so a caller can say "content changed" or "meta.title added"
101
+ // without re-deriving the comparison.
102
+ export function diffInputParts(before, after) {
103
+ const from = before ?? {}
104
+ const to = after ?? {}
105
+ const changed = [], added = [], removed = []
106
+ for (const key of Object.keys(to)) {
107
+ if (!(key in from)) added.push(key)
108
+ else if (from[key] !== to[key]) changed.push(key)
109
+ }
110
+ for (const key of Object.keys(from)) {
111
+ if (!(key in to)) removed.push(key)
112
+ }
113
+ return { changed: changed.sort(), added: added.sort(), removed: removed.sort() }
50
114
  }
51
115
 
52
116
  // Canonical lookup variants for an entity — the same four forms the