mikser-io 9.15.1 → 9.18.0

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": "9.15.1",
3
+ "version": "9.18.0",
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
@@ -207,16 +207,19 @@ async function applyJournalMutations() {
207
207
  }
208
208
  if (!mutations.length) return
209
209
  db.transaction(() => {
210
+ // Two passes, deliberately. indexEntity resolves each $-ref
211
+ // against mikser_entities to record what it bound to, so it has
212
+ // to run after every row this cycle is in place — otherwise a
213
+ // ref to an entity that happens to be upserted later in the same
214
+ // batch binds to nothing, purely because of journal order.
215
+ const toIndex = []
210
216
  for (const { operation, entity } of mutations) {
211
217
  switch (operation) {
212
218
  case OPERATION.CREATE:
213
219
  case OPERATION.UPDATE:
214
220
  logger.trace('Database %s %s: %s', entity.collection, operation, entity.id)
215
221
  stmtUpsert.run(entityToRow(entity))
216
- // Static $-ref edges from entity.meta. Refs index
217
- // does delete-then-insert internally so this is
218
- // idempotent across UPDATE.
219
- refsIndex?.indexEntity(entity)
222
+ toIndex.push(entity)
220
223
  cacheEvict(entity.id) // next read returns fresh data
221
224
  break
222
225
  case OPERATION.DELETE:
@@ -227,6 +230,10 @@ async function applyJournalMutations() {
227
230
  break
228
231
  }
229
232
  }
233
+ // Static $-ref edges from entity.meta. Refs index does
234
+ // delete-then-insert per source internally, so this stays
235
+ // idempotent across UPDATE.
236
+ for (const entity of toIndex) refsIndex?.indexEntity(entity)
230
237
  })
231
238
  }
232
239
 
package/src/config.js CHANGED
@@ -3,6 +3,7 @@ import { useLogger } from './engine.js'
3
3
  import { onLoad } from './lifecycle.js'
4
4
  import { checksum } from './utils.js'
5
5
  import path from 'node:path'
6
+ import { existsSync } from 'node:fs'
6
7
 
7
8
  onLoad(async () => {
8
9
  const logger = useLogger()
@@ -29,15 +30,31 @@ onLoad(async () => {
29
30
  runtime.options.configChecksum = null
30
31
  }
31
32
 
32
- try {
33
+ // Absence is decided by looking for the file, NOT by catching
34
+ // ERR_MODULE_NOT_FOUND from the import.
35
+ //
36
+ // Node raises that same code for "the config file is missing" and for
37
+ // "the config file exists and something IT imports is missing" — a
38
+ // mistyped package name, a renamed local module, a dependency that
39
+ // was never installed. Catching the code swallowed both, so a config
40
+ // with one bad import loaded as `{}` and the build reported "No
41
+ // plugins loaded" and exited 0: a green build with an empty output
42
+ // folder, one line away from having printed the config's path.
43
+ //
44
+ // Every other config failure was already loud — a syntax error or a
45
+ // throw during evaluation both exit 1. Module resolution was the one
46
+ // silent case, so this brings it in line rather than inventing a new
47
+ // policy.
48
+ if (!existsSync(configFile)) {
49
+ logger.debug('No config file at %s — using defaults', configFile)
50
+ } else {
51
+ // No catch: any failure loading a config that EXISTS is fatal.
33
52
  const config = await import(configFile)
34
53
  if (typeof config.default == 'function') {
35
54
  runtime.config = await config.default(runtime)
36
55
  } else if (typeof config.default == 'object') {
37
56
  runtime.config = config.default
38
57
  }
39
- } catch (err) {
40
- if (err.code != 'ERR_MODULE_NOT_FOUND') throw err
41
58
  }
42
59
 
43
60
  // v8 used to walk `runtime.config.plugins` looking for matching
package/src/explain.js CHANGED
@@ -114,7 +114,18 @@ export async function explain(reference) {
114
114
  refClosure: (snap.refClosure ?? []).map(entry =>
115
115
  entry.kind === 'query'
116
116
  ? { kind: 'query', filter: entry.filter }
117
- : { kind: entry.kind, target: entry.target, hash: shortHash(entry.hash) }),
117
+ : {
118
+ kind: entry.kind,
119
+ target: entry.target,
120
+ // What the name actually resolved to. A binding
121
+ // that is absent means the edge is dangling —
122
+ // the single most useful thing to know when a
123
+ // page will not re-render and nobody can say why.
124
+ bound: entry.targetIds?.length ? entry.targetIds
125
+ : entry.targetId ? [entry.targetId]
126
+ : [],
127
+ hash: shortHash(entry.hash),
128
+ }),
118
129
  })),
119
130
  // What a plain build would do next, stated plainly.
120
131
  verdict: source?.error === 'file is gone'
@@ -182,9 +193,17 @@ export function formatExplain(report) {
182
193
  const closure = r.refClosure
183
194
  row('refClosure', `${closure.length} edge${closure.length === 1 ? '' : 's'}`)
184
195
  for (const e of closure) {
185
- out.push(e.kind === 'query'
186
- ? ` query ${JSON.stringify(e.filter)}`
187
- : ` ${e.kind.padEnd(10)} ${e.target}${e.hash ? ` ${e.hash}` : ''}`)
196
+ if (e.kind === 'query') {
197
+ out.push(` query ${JSON.stringify(e.filter)}`)
198
+ continue
199
+ }
200
+ // Show the name asked for and the entity it bound to, since
201
+ // the two can differ (an href or a served path resolves to
202
+ // an id) and a missing binding is itself the diagnosis.
203
+ const bound = e.bound?.length
204
+ ? (e.bound.length === 1 && e.bound[0] === e.target ? '' : ` → ${e.bound.join(', ')}`)
205
+ : ' [UNRESOLVED — nothing answers to this name]'
206
+ out.push(` ${e.kind.padEnd(10)} ${e.target}${bound}${e.hash ? ` ${e.hash}` : ''}`)
188
207
  }
189
208
  }
190
209
 
package/src/manifest.js CHANGED
@@ -96,12 +96,20 @@ function sha1(payload) {
96
96
  function buildRefClosure(entity, deps) {
97
97
  const closure = []
98
98
  const seen = new Set()
99
- function pushTarget(kind, target, hash) {
99
+ // `targetId`/`targetIds` are the recorded BINDING — which entity the
100
+ // ref actually resolved to. They have to survive into the snapshot:
101
+ // this projection used to keep only {kind, target, hash}, so the
102
+ // binding was written to mikser_refs, dropped here, and skipDecision
103
+ // had nothing but the name to compare. The scheduler found the
104
+ // dependent and the manifest then skipped it.
105
+ function pushTarget(kind, target, hash, targetId, targetIds) {
100
106
  if (!target) return
101
107
  const key = `${kind}:${target}`
102
108
  if (seen.has(key)) return
103
109
  seen.add(key)
104
110
  const entry = { kind, target }
111
+ if (targetId) entry.targetId = targetId
112
+ if (targetIds?.length) entry.targetIds = targetIds
105
113
  if (hash) entry.hash = hash
106
114
  closure.push(entry)
107
115
  }
@@ -113,14 +121,26 @@ function buildRefClosure(entity, deps) {
113
121
  }
114
122
  if (entity.meta) {
115
123
  for (const { ref } of extractRefs(entity.meta)) {
116
- const target = findById(ref)
117
- pushTarget('ref', ref, target ? inputHashOf(target) : undefined)
124
+ // Resolve through the refs index, which mirrors refFilter's
125
+ // four forms. findById alone — as this used to do — is an
126
+ // exact primary-key read: a $-ref written as a meta.href or
127
+ // a served meta.url path (ADR-0011) resolved to nothing, so
128
+ // the edge was stored with no hash and no binding, and the
129
+ // manifest could not tell the target had changed.
130
+ const ids = runtime.refs?.resolveRefIds?.(ref) ?? (findById(ref) ? [ref] : [])
131
+ const bound = ids.length === 1 ? findById(ids[0]) : null
132
+ pushTarget(
133
+ 'ref', ref,
134
+ bound ? inputHashOf(bound) : undefined,
135
+ ids.length === 1 ? ids[0] : undefined,
136
+ ids.length > 1 ? ids : undefined,
137
+ )
118
138
  }
119
139
  }
120
140
  if (Array.isArray(deps)) {
121
141
  for (const dep of deps) {
122
142
  if (dep.kind === 'query') pushQuery(dep.filter ?? null)
123
- else pushTarget(dep.kind, dep.target, dep.hash)
143
+ else pushTarget(dep.kind, dep.target, dep.hash, dep.targetId, dep.targetIds)
124
144
  }
125
145
  }
126
146
  return closure
@@ -286,7 +306,20 @@ export function createManifest(db) {
286
306
  // ref-changed a $-ref or partial it depends on moved
287
307
  // query-matched an entity matching a recorded query mutated
288
308
  // cache-disabled meta.cache === false
309
+ // force --force: skip nothing, ask nothing
289
310
  skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
311
+ // --force means "ignore what you think you know". THREE gates can
312
+ // stop a render — source.js's import checksum gate, layouts'
313
+ // dispatch filter, and this one — and force reached only the
314
+ // first two. Since this one runs last, a forced build re-imported
315
+ // everything (gated=0), re-dispatched everything, and then
316
+ // dropped all of it here with reason `unchanged`: rendered=0, and
317
+ // a summary that read like a successful build.
318
+ //
319
+ // That made --force useless in exactly the situation it exists
320
+ // for, which is when the invalidation graph is under suspicion —
321
+ // including the advice the preset no-match warning gives.
322
+ if (runtime.options?.force) return { skip: false, reason: 'force' }
290
323
  if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
291
324
  const snapshot = this.lookup(entity)
292
325
  if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
@@ -303,26 +336,48 @@ export function createManifest(db) {
303
336
  }
304
337
  continue
305
338
  }
306
- if (!mutatedRefs?.has(entry.target)) continue
307
- // Language scope: if the source has a meta.lang AND the
308
- // mutation came from a different meta.lang, the ref
309
- // doesn't actually depend on what changed. A `null` lang
310
- // in the mutation set means a shared (un-localized)
311
- // entity touched the same key — that does invalidate
312
- // language-specific sources because the shared entity
313
- // is the only variant. Sources without a meta.lang are
314
- // language-agnostic and accept any mutation lang.
315
- if (sourceLang) {
316
- const mutatedLangs = mutatedRefs.get(entry.target)
317
- if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
318
- continue
339
+ // An edge is checked against every key it could have been
340
+ // hit by: the entity it BOUND to, and the name it asked
341
+ // for. entity.id is always lookupKeys()[0], so the engine's
342
+ // mutation maps are already keyed by id — reading them by
343
+ // targetId needs no change there, and a binding survives
344
+ // the target renaming itself.
345
+ //
346
+ // Both, not the first match, because the two can disagree:
347
+ // the bound entity may have been re-persisted unchanged in
348
+ // the same cycle that a DIFFERENT entity started answering
349
+ // to the same name. Stopping at the binding would compare
350
+ // an unchanged hash and skip, silently ignoring the new
351
+ // claimant. The name key also carries unresolved/forward
352
+ // edges, which is how a link to a not-yet-existing page
353
+ // invalidates once that page appears.
354
+ const keys = [
355
+ ...(entry.targetIds ?? []),
356
+ ...(entry.targetId ? [entry.targetId] : []),
357
+ entry.target,
358
+ ]
359
+ for (const key of keys) {
360
+ if (!mutatedRefs?.has(key)) continue
361
+ // Language scope: if the source has a meta.lang AND the
362
+ // mutation came from a different meta.lang, the ref
363
+ // doesn't actually depend on what changed. A `null` lang
364
+ // in the mutation set means a shared (un-localized)
365
+ // entity touched the same key — that does invalidate
366
+ // language-specific sources because the shared entity
367
+ // is the only variant. Sources without a meta.lang are
368
+ // language-agnostic and accept any mutation lang.
369
+ if (sourceLang) {
370
+ const mutatedLangs = mutatedRefs.get(key)
371
+ if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
372
+ continue
373
+ }
319
374
  }
375
+ if (!entry.hash) return { skip: false, reason: 'ref-changed' }
376
+ const currentHash = currentHashes?.get(key)
377
+ if (currentHash === undefined) continue
378
+ if (currentHash === null) return { skip: false, reason: 'ref-changed' }
379
+ if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
320
380
  }
321
- if (!entry.hash) return { skip: false, reason: 'ref-changed' }
322
- const currentHash = currentHashes?.get(entry.target)
323
- if (currentHash === undefined) continue
324
- if (currentHash === null) return { skip: false, reason: 'ref-changed' }
325
- if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
326
381
  }
327
382
  return { skip: true, reason: 'unchanged' }
328
383
  },
@@ -427,6 +482,7 @@ export function createManifest(db) {
427
482
  edges.push({
428
483
  kind: 'layout',
429
484
  target: entity.layout.id,
485
+ targetId: entity.layout.id,
430
486
  hash: inputHashOf(entity.layout),
431
487
  })
432
488
  }
@@ -436,23 +492,35 @@ export function createManifest(db) {
436
492
  edges.push({
437
493
  kind: 'partial',
438
494
  target,
495
+ targetId: partial ? target : undefined,
439
496
  hash: partial ? inputHashOf(partial) : undefined,
440
497
  })
441
498
  }
442
499
  }
443
500
  if (track?.lookups) {
444
- for (const target of track.lookups) {
445
- // findById is extension-tolerant and also resolves a
446
- // meta.href, so the hash is the resolved entity's when
447
- // there is one. No hash means "nothing resolved" —
448
- // shouldSkip treats a hashless edge whose target mutated
449
- // as a re-render, which is what should happen when a page
501
+ for (const [target, ids] of track.lookups) {
502
+ // The lookup helper resolved this already and handed
503
+ // over the ids, so the hash comes from the BOUND
504
+ // entity. Hashing findById(target) instead — as this
505
+ // did — silently produced no hash at all whenever the
506
+ // target was an href or url form, because findById is
507
+ // an exact primary-key read: it resolves neither
508
+ // meta.href nor meta.url nor a stripped extension.
509
+ // Every such edge was hashless, so the manifest could
510
+ // not tell "target moved" from "target changed".
511
+ //
512
+ // No hash still means "nothing resolved", and
513
+ // skipDecision re-renders a hashless edge whose target
514
+ // mutated — which is what should happen when a page
450
515
  // that was linked-to-but-missing finally appears.
451
- const resolved = findById(target)
516
+ const targetIds = [...ids]
517
+ const bound = targetIds.length === 1 ? findById(targetIds[0]) : null
452
518
  edges.push({
453
519
  kind: 'lookup',
454
520
  target,
455
- hash: resolved ? inputHashOf(resolved) : undefined,
521
+ targetId: targetIds.length === 1 ? targetIds[0] : undefined,
522
+ targetIds: targetIds.length > 1 ? targetIds : undefined,
523
+ hash: bound ? inputHashOf(bound) : undefined,
456
524
  })
457
525
  }
458
526
  }
@@ -86,25 +86,41 @@ export function files(options = {}) {
86
86
  link: await link(source)
87
87
  })
88
88
  break
89
- case ACTION.UPDATE:
89
+ case ACTION.UPDATE: {
90
90
  const current = await findEntity({ id })
91
- if (current?.checksum != checksum) {
91
+ // `checksum` is the source-checksum FUNCTION from the
92
+ // plugin context, not a value. Comparing the stored
93
+ // string against it was never equal, so the guard
94
+ // always passed and every sync re-wrote the entity —
95
+ // `synced = false` was unreachable.
96
+ const currentChecksum = await checksum(source)
97
+ if (current?.checksum != currentChecksum) {
92
98
  await updateEntity({
93
99
  id,
94
100
  uri,
95
- name: relativePath,
101
+ // `name` — the prefixed form, as CREATE uses.
102
+ // This read `relativePath`, so an update
103
+ // dropped the outputFolder prefix while
104
+ // meta.url two lines down kept it. The assets
105
+ // plugin builds preset destinations from
106
+ // `name`, so a file replaced under watch had
107
+ // its derivatives written somewhere else than
108
+ // the same file freshly imported, and
109
+ // meta.presets recorded the wrong path.
110
+ name,
96
111
  collection,
97
112
  type,
98
113
  format,
99
114
  source,
100
115
  meta: { url: '/' + name },
101
- checksum: await checksum(source),
116
+ checksum: currentChecksum,
102
117
  link: await link(source)
103
118
  })
104
119
  } else {
105
120
  synced = false
106
121
  }
107
122
  break
123
+ }
108
124
  case ACTION.DELETE:
109
125
  await removeLink(relativePath)
110
126
  await deleteEntity({
@@ -143,6 +159,13 @@ export function files(options = {}) {
143
159
  // journal with phantom mutations and triggering downstream
144
160
  // re-dispatch of aggregate layouts whose recorded query deps
145
161
  // matched the collection.
162
+ // --force (and a wiped catalog) must defeat the gate, the same
163
+ // way source.js's gateChecksum lets them defeat its own. This
164
+ // plugin carries a second, independent gate, and it honoured
165
+ // neither — so no amount of forcing ever re-derived a file's
166
+ // name / meta.url / meta.presets, and a catalog holding bad
167
+ // `files` rows had no repair path short of deleting them.
168
+ const forced = runtime.options.force || runtime.catalog?.cacheInvalidated
146
169
  const priorChecksums = checksumsByCollection(collection)
147
170
  await pMap(paths, async relativePath => {
148
171
  const { uri, source } = await ensureLink(relativePath)
@@ -159,7 +182,7 @@ export function files(options = {}) {
159
182
  // the journal stays accurate (mutations = actual changes),
160
183
  // and downstream aggregate-layout invalidation isn't fired
161
184
  // spuriously.
162
- if (priorChecksums.get(id) === newChecksum) return
185
+ if (!forced && priorChecksums.get(id) === newChecksum) return
163
186
  await createEntity({
164
187
  id,
165
188
  uri,
package/src/plugins.js CHANGED
@@ -87,7 +87,21 @@ onLoad(() => {
87
87
  }
88
88
 
89
89
  if (!factoryEntries.length && !registeredRenderers && !registeredPostprocessors) {
90
- logger.info('No plugins loaded')
90
+ // "No plugins loaded" is a legitimate state for a project with no
91
+ // config at all, and a near-certain mistake for one that HAS a
92
+ // config — the two printed the same line, so a config that
93
+ // produced no plugins looked like a deliberate choice. Say which
94
+ // case this is.
95
+ if (runtime.options.configChecksum) {
96
+ logger.warn(
97
+ 'No plugins loaded, but a config was read from %s — ' +
98
+ 'it exported no `plugins` array, or the array was empty. ' +
99
+ 'Nothing will be built.',
100
+ runtime.options.config,
101
+ )
102
+ } else {
103
+ logger.info('No plugins loaded')
104
+ }
91
105
  return
92
106
  }
93
107
 
package/src/refs.js CHANGED
@@ -14,7 +14,26 @@
14
14
  // the engine after each successful render
15
15
  // via the track API. field = '' (empty).
16
16
  //
17
- // Lookups are indexed SELECTs over (target_ref, kind) and (source_id):
17
+ // Every row carries BOTH the question and the answer:
18
+ //
19
+ // target_ref — the string the dependent asked for. May be an id, a
20
+ // meta.href, a meta.url (ADR-0011 served path), or an
21
+ // id with its extension stripped.
22
+ // target_id — the entity it actually resolved to, '' when nothing
23
+ // did. NOT NULL because WITHOUT ROWID requires it of
24
+ // primary-key columns, and it is in the key so one
25
+ // dependent can bind one name to several entities
26
+ // (language variants).
27
+ //
28
+ // Both are load-bearing. Answering "who depends on X?" from target_ref
29
+ // alone means guessing which strings X could have answered to, which
30
+ // fails the moment X changes its name — the recorded string is then
31
+ // derivable from no live state. target_id alone cannot express a
32
+ // forward or dangling reference, nor re-bind when an entity later
33
+ // claims a name nobody could resolve before.
34
+ //
35
+ // Lookups are indexed SELECTs over (target_ref, kind), (target_id) and
36
+ // (source_id):
18
37
  //
19
38
  // inbound :: "Which entities reference this target_ref?"
20
39
  // outbound :: "Which target_refs does this source emit?"
@@ -47,17 +66,25 @@ import { registerSchema, useDatabase } from './database/index.js'
47
66
  // who references X). The primary key's leading source_id column
48
67
  // already covers forward (everything X references) without a
49
68
  // separate index.
50
- registerSchema('mikser_refs', `
69
+ //
70
+ // Exported so tests build their index over the REAL schema instead of a
71
+ // hand-copied one. The copy in test/unit/refs.test.js silently drifted
72
+ // out of date the moment target_id was added — 25 tests failed with a
73
+ // bare SQLITE_ERROR pointing at nothing in particular.
74
+ export const REFS_SCHEMA = `
51
75
  CREATE TABLE IF NOT EXISTS mikser_refs (
52
76
  source_id TEXT NOT NULL,
53
77
  target_ref TEXT NOT NULL,
78
+ target_id TEXT NOT NULL DEFAULT '',
54
79
  kind TEXT NOT NULL,
55
80
  field TEXT NOT NULL DEFAULT '',
56
- PRIMARY KEY (source_id, target_ref, kind, field),
81
+ PRIMARY KEY (source_id, target_ref, kind, field, target_id),
57
82
  FOREIGN KEY (source_id) REFERENCES mikser_entities(id) ON DELETE CASCADE
58
83
  ) WITHOUT ROWID;
59
84
  CREATE INDEX IF NOT EXISTS idx_mikser_refs_target ON mikser_refs(target_ref);
60
- `)
85
+ CREATE INDEX IF NOT EXISTS idx_mikser_refs_target_id ON mikser_refs(target_id);
86
+ `
87
+ registerSchema('mikser_refs', REFS_SCHEMA)
61
88
 
62
89
  // Build the index handle over the provided sqlite database. Prepares
63
90
  // the SQL statements once at construction; subsequent reads/writes
@@ -82,6 +109,14 @@ export function createIndex(db) {
82
109
  SELECT DISTINCT source_id FROM mikser_refs
83
110
  WHERE target_ref = ?
84
111
  `)
112
+ // Identity-direction inbound: everything BOUND to this entity, under
113
+ // whatever name it was asked for. Immune to the target renaming
114
+ // itself, which is the whole point — the binding was recorded when
115
+ // it was made instead of re-derived from the target's current names.
116
+ const stmtInboundByTargetId = db.prepare(`
117
+ SELECT DISTINCT source_id FROM mikser_refs
118
+ WHERE target_id = ? AND target_id != ''
119
+ `)
85
120
  const stmtInboundDynamic = db.prepare(`
86
121
  SELECT source_id, kind FROM mikser_refs
87
122
  WHERE target_ref = ? AND kind != 'ref'
@@ -109,6 +144,45 @@ export function createIndex(db) {
109
144
  SELECT COUNT(DISTINCT source_id) AS c FROM mikser_refs WHERE kind != 'ref'
110
145
  `)
111
146
 
147
+ // Synchronous resolution of a ref string to entity ids. Mirrors
148
+ // `refFilter` in utils.js — id, meta.href, meta.url, then
149
+ // id-minus-extension — but as sync SQL, because indexEntity runs
150
+ // inside catalog.applyJournalMutations' transaction and
151
+ // better-sqlite3 transactions are sync-only.
152
+ //
153
+ // Returns every match, not the first: a name shared by language
154
+ // variants binds to all of them, and over-binding only ever means
155
+ // more invalidation, which the manifest's hash check then filters.
156
+ const stmtResolveExact = db.prepare(`
157
+ SELECT id FROM mikser_entities
158
+ WHERE id = ? OR meta_href = ? OR meta_url = ?
159
+ `)
160
+ const stmtResolveExtended = db.prepare(`
161
+ SELECT id FROM mikser_entities WHERE id GLOB ?
162
+ `)
163
+ function resolveRefIds(ref) {
164
+ if (typeof ref !== 'string' || !ref) return []
165
+ const ids = new Set()
166
+ for (const row of stmtResolveExact.all(ref, ref, ref)) ids.add(row.id)
167
+ // The extension-tolerant form. GLOB narrows to `<ref>.something`
168
+ // using the primary key's prefix; the regex then enforces
169
+ // EXACTLY one trailing extension and no further path segment,
170
+ // matching refFilter's `^ref\.[^./]+$`.
171
+ //
172
+ // SQLite's GLOB has no escape syntax, so a ref containing GLOB
173
+ // metacharacters cannot be used as a literal prefix. Skip the
174
+ // narrowing rather than run a pattern that means something else
175
+ // than the caller wrote; the exact forms above still apply, and
176
+ // the name-direction query still covers the edge.
177
+ if (/[*?[\]]/.test(ref)) return [...ids]
178
+ const escaped = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
179
+ const oneExtension = new RegExp(`^${escaped}\\.[^./]+$`)
180
+ for (const row of stmtResolveExtended.all(`${ref}.*`)) {
181
+ if (oneExtension.test(row.id)) ids.add(row.id)
182
+ }
183
+ return [...ids]
184
+ }
185
+
112
186
  // Write statements
113
187
  const stmtClearStaticForSource = db.prepare(`
114
188
  DELETE FROM mikser_refs WHERE source_id = ? AND kind = 'ref'
@@ -117,8 +191,8 @@ export function createIndex(db) {
117
191
  DELETE FROM mikser_refs WHERE source_id = ? AND kind != 'ref'
118
192
  `)
119
193
  const stmtInsertEdge = db.prepare(`
120
- INSERT OR IGNORE INTO mikser_refs (source_id, target_ref, kind, field)
121
- VALUES (?, ?, ?, ?)
194
+ INSERT OR IGNORE INTO mikser_refs (source_id, target_ref, target_id, kind, field)
195
+ VALUES (?, ?, ?, ?, ?)
122
196
  `)
123
197
 
124
198
  // -- Read API ------------------------------------------------------
@@ -168,25 +242,45 @@ export function createIndex(db) {
168
242
  // schemas / findRef use. Cycle-safe via the result Set.
169
243
  function inverseClosureOf(seeds, getEntityById) {
170
244
  const closure = new Set()
171
- const keysToWalk = []
245
+ const toWalk = []
246
+
247
+ const enqueue = (id, entity) => {
248
+ if (!id || closure.has(id)) return
249
+ closure.add(id)
250
+ toWalk.push({ id, entity })
251
+ }
172
252
 
173
253
  for (const seed of seeds ?? []) {
174
254
  const entity = typeof seed === 'string' ? null : seed
175
- const id = entity?.id ?? (typeof seed === 'string' ? seed : null)
176
- if (!id) continue
177
- if (closure.has(id)) continue
178
- closure.add(id)
179
- keysToWalk.push(...lookupKeys(entity ?? { id }))
255
+ enqueue(entity?.id ?? (typeof seed === 'string' ? seed : null), entity)
180
256
  }
181
257
 
182
- while (keysToWalk.length > 0) {
183
- const key = keysToWalk.shift()
184
- const referrers = stmtInboundAny.all(key)
185
- for (const { source_id: id } of referrers) {
186
- if (closure.has(id)) continue
187
- closure.add(id)
188
- const entity = getEntityById?.(id)
189
- keysToWalk.push(...lookupKeys(entity ?? { id }))
258
+ while (toWalk.length > 0) {
259
+ const { id, entity } = toWalk.shift()
260
+ const referrers = new Set()
261
+
262
+ // Identity direction: edges BOUND to this entity. Survives
263
+ // the entity renaming itself, because the binding was
264
+ // recorded at resolve time rather than reconstructed from
265
+ // whatever names it happens to carry now.
266
+ for (const row of stmtInboundByTargetId.all(id)) referrers.add(row.source_id)
267
+
268
+ // Name direction: edges that ASKED for a name this entity
269
+ // answers to. Covers unresolved/forward references, and an
270
+ // entity that has just started answering to a name nobody
271
+ // could resolve before.
272
+ //
273
+ // Union rather than replacement: strictly a superset of the
274
+ // pre-binding behaviour, so nothing that invalidated before
275
+ // can stop invalidating now. Over-approximating here is
276
+ // cheap — manifest.skipDecision still compares hashes and
277
+ // drops anything that did not actually change.
278
+ for (const key of lookupKeys(entity ?? { id })) {
279
+ for (const row of stmtInboundAny.all(key)) referrers.add(row.source_id)
280
+ }
281
+
282
+ for (const sourceId of referrers) {
283
+ enqueue(sourceId, getEntityById?.(sourceId))
190
284
  }
191
285
  }
192
286
 
@@ -208,7 +302,18 @@ export function createIndex(db) {
208
302
  stmtClearStaticForSource.run(entity.id)
209
303
  if (!entity.meta) return
210
304
  for (const { path, ref } of extractRefs(entity.meta)) {
211
- stmtInsertEdge.run(entity.id, ref, 'ref', path)
305
+ // Resolve now and record what it bound to. An unresolvable
306
+ // ref still gets its row with target_id '' — a forward
307
+ // reference is a real dependency, and the name-direction
308
+ // query is what will find it when the target appears.
309
+ const targetIds = resolveRefIds(ref)
310
+ if (!targetIds.length) {
311
+ stmtInsertEdge.run(entity.id, ref, '', 'ref', path)
312
+ continue
313
+ }
314
+ for (const targetId of targetIds) {
315
+ stmtInsertEdge.run(entity.id, ref, targetId, 'ref', path)
316
+ }
212
317
  }
213
318
  }
214
319
 
@@ -220,9 +325,16 @@ export function createIndex(db) {
220
325
  db.transaction(() => {
221
326
  stmtClearDynamicForSource.run(sourceId)
222
327
  if (!edges?.length) return
223
- for (const { kind, target } of edges) {
328
+ for (const { kind, target, targetId, targetIds } of edges) {
224
329
  if (!kind || !target) continue
225
- stmtInsertEdge.run(sourceId, target, kind, '')
330
+ // Render-time edges already know what they resolved to —
331
+ // the lookup helper had the entity in hand — so no
332
+ // re-resolution here. `targetIds` carries the plural
333
+ // case (one name, several language variants).
334
+ const ids = targetIds?.length ? targetIds : targetId ? [targetId] : ['']
335
+ for (const id of ids) {
336
+ stmtInsertEdge.run(sourceId, target, id, kind, '')
337
+ }
226
338
  }
227
339
  })
228
340
  }
@@ -260,6 +372,12 @@ export function createIndex(db) {
260
372
  dynamicInboundFor,
261
373
  dynamicOutboundFor,
262
374
  inverseClosureOf,
375
+ // Synchronous ref -> entity ids, mirroring refFilter's four
376
+ // forms. Exposed because the manifest needs the same binding
377
+ // the index records: hashing a static $-ref with catalog's
378
+ // findById only ever worked for id-form refs, since findById is
379
+ // an exact primary-key read.
380
+ resolveRefIds,
263
381
  // Write
264
382
  indexEntity,
265
383
  replaceDynamic,
@@ -459,6 +577,11 @@ export function createRefs(db, prebuiltIndex = null) {
459
577
 
460
578
  inverseClosureOf: (seeds) => index.inverseClosureOf(seeds, findById),
461
579
 
580
+ // Resolve a ref string to the entity ids it binds to — the same
581
+ // four forms refFilter uses, synchronously. The manifest needs
582
+ // it to hash a static $-ref correctly.
583
+ resolveRefIds: (ref) => index.resolveRefIds(ref),
584
+
462
585
  // Replace dynamic edges for a source — called by the engine
463
586
  // after a successful render via the track API.
464
587
  replaceDynamic: (sourceId, edges) => index.replaceDynamic(sourceId, edges),
package/src/render.js CHANGED
@@ -74,9 +74,16 @@ function lookupHrefViaDb(href) {
74
74
  // `origin` (runtime.options.url) makes it absolute so static outputs —
75
75
  // emails, feeds — carry a whole URL; absent, it stays base-relative.
76
76
  // Unresolved refs return unchanged, staying visible like lookupHref.
77
- function lookupUrlViaDb(ref, preset, origin) {
77
+ function lookupUrlViaDb(ref, preset, origin, track) {
78
78
  if (typeof ref !== 'string') return ref
79
79
  const row = stmtIdLookup.get(ref)
80
+ // Record the dependency here, where the row is already read, rather
81
+ // than in the wrapper — a second lookup per call would be paid on
82
+ // every image and media reference in a template. stmtIdLookup is an
83
+ // exact-id read, so a hit means the bound entity IS `ref`; a miss
84
+ // records the name with no binding, which is a forward reference and
85
+ // still a real dependency.
86
+ track?.lookup?.(ref, row ? ref : null)
80
87
  if (!row) return ref
81
88
  const meta = JSON.parse(row.data).meta || {}
82
89
  // A template helper called with one arg still receives the renderer's
@@ -196,19 +203,22 @@ export default async ({ entity, options, config, context, state, logger, port, t
196
203
  // no edge to check. A sidecar's findEntities() was tracked all along;
197
204
  // these two were the asymmetry.
198
205
  //
199
- // The recorded target is the string the template asked for, not the
200
- // resolved id — see track.lookup for why that also covers a target that
201
- // does not exist yet.
206
+ // Both record the name asked for AND what it bound to. The
207
+ // entities are already in the result, so extracting the ids is
208
+ // free — see track.lookup for why both halves are needed.
202
209
  lookupHref: (href) => {
203
- track?.lookup?.(href)
204
- return lookupHrefViaDb(href)
210
+ const result = lookupHrefViaDb(href)
211
+ // Either a bare entity or a { lang: entity } map — see
212
+ // lookupHrefViaDb's contract above.
213
+ const ids = !result ? []
214
+ : typeof result.id === 'string' ? [result.id]
215
+ : Object.values(result).map(e => e?.id).filter(Boolean)
216
+ track?.lookup?.(href, ids)
217
+ return result
205
218
  },
206
219
  // Resolve a served-entity reference to its deployed URL, absolute
207
220
  // when runtime.options.url is set (ADR-0011).
208
- lookupUrl: (ref, preset) => {
209
- if (typeof ref === 'string') track?.lookup?.(ref)
210
- return lookupUrlViaDb(ref, preset, options.url)
211
- },
221
+ lookupUrl: (ref, preset) => lookupUrlViaDb(ref, preset, options.url, track),
212
222
  content() {
213
223
  return readFileSync(entity.source, { encoding: 'utf8' })
214
224
  },
package/src/report.js CHANGED
@@ -13,7 +13,7 @@ import runtime from './runtime.js'
13
13
 
14
14
  function store() {
15
15
  runtime.state ??= {}
16
- runtime.state.report ??= { rendered: [], skipped: [], warnings: [], gated: 0 }
16
+ runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], warnings: [], gated: 0 }
17
17
  return runtime.state.report
18
18
  }
19
19
 
@@ -33,6 +33,17 @@ export function reportRendered(entity, reason) {
33
33
  store().rendered.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
34
34
  }
35
35
 
36
+ // A render that RAN and produced bytes identical to what was already on
37
+ // disk. Distinct from both other outcomes and the interesting one of the
38
+ // three: `rendered` means the output moved, `skipped` means the manifest
39
+ // decided not to look, and this means invalidation was coarser than it
40
+ // needed to be. Nothing downstream should have been disturbed, and the
41
+ // count is the measure of how much conservative invalidation costs.
42
+ export function reportUnchanged(entity) {
43
+ if (!runtime.options?.json) return
44
+ store().unchanged.push({ id: entity?.id, destination: entity?.destination ?? null })
45
+ }
46
+
36
47
  export function reportSkipped(entity, reason) {
37
48
  if (!runtime.options?.json) return
38
49
  store().skipped.push({ id: entity?.id, destination: entity?.destination ?? null, reason })
@@ -54,9 +65,13 @@ export function buildReport() {
54
65
  return {
55
66
  rendered: report.rendered,
56
67
  skipped: report.skipped,
68
+ unchanged: report.unchanged,
57
69
  warnings: report.warnings,
58
70
  summary: {
59
71
  rendered: report.rendered.length,
72
+ // Of those renders, how many produced bytes identical to
73
+ // what was already on disk — see reportUnchanged.
74
+ unchanged: report.unchanged.length,
60
75
  // Renders that were CONSIDERED and skipped by the manifest.
61
76
  skipped: report.skipped.length,
62
77
  // Entities gated at import because their source was unchanged, so
package/src/track.js CHANGED
@@ -60,15 +60,26 @@ export function createTrack({ partial = true, query = true, lookup = true } = {}
60
60
  // the manifest and never in the refs index — recorded, and still
61
61
  // never scheduling a re-render.
62
62
  //
63
- // The target is the STRING the template asked for, not the resolved
64
- // entity's id, and that is deliberate: lookupKeys() expands a mutated
65
- // entity into its id, its meta.href AND its id-minus-extension, so an
66
- // edge on '/contacts' fires whether the target was edited, renamed,
67
- // deleted, or created for the first time. Recording the resolved id
68
- // instead would miss the case where nothing resolved yet.
69
- const lookups = new Set()
63
+ // Records BOTH the string asked for and what it resolved to.
64
+ // The string alone cannot survive the target renaming itself;
65
+ // the resolved id alone cannot express a link to a page that
66
+ // does not exist yet. mikser_refs keeps both columns for the
67
+ // same reason, and the two invalidation directions read one
68
+ // each.
69
+ //
70
+ // A name can resolve to several entities — language variants
71
+ // share a meta.href — so the value is a Set of ids, empty when
72
+ // the lookup found nothing.
73
+ const lookups = new Map()
70
74
  track.lookups = lookups
71
- track.lookup = (target) => { if (target && typeof target === 'string') lookups.add(target) }
75
+ track.lookup = (target, resolvedIds) => {
76
+ if (!target || typeof target !== 'string') return
77
+ let ids = lookups.get(target)
78
+ if (!ids) lookups.set(target, ids = new Set())
79
+ for (const id of Array.isArray(resolvedIds) ? resolvedIds : resolvedIds ? [resolvedIds] : []) {
80
+ if (id && typeof id === 'string') ids.add(id)
81
+ }
82
+ }
72
83
  }
73
84
  if (partial) {
74
85
  const partials = new Set()
package/src/utils.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { hashFile } from 'hasha'
4
- import { stat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
4
+ import { stat, lstat, readFile, writeFile, mkdir, unlink, open } from 'node:fs/promises'
5
5
  import { createRequire } from 'node:module'
6
6
  import _ from 'lodash'
7
7
  import { minimatch } from 'minimatch'
@@ -17,15 +17,26 @@ import runtime from './runtime.js'
17
17
  // Pure: synchronous, no I/O, no engine state.
18
18
  export function inputHashOf(entity) {
19
19
  if (!entity) return ''
20
- // For file-only entities (no meta/content surface) the upstream
21
- // file-content checksum from `checksum()` above is the authoritative
22
- // fingerprint and is already computed.
23
- if (entity.checksum && entity.meta == null && entity.content == null) {
24
- return crypto.createHash('sha1').update(String(entity.checksum)).digest('hex')
25
- }
26
20
  return crypto.createHash('sha1').update(JSON.stringify({
27
21
  meta: entity.meta ?? null,
28
22
  content: entity.content ?? null,
23
+ // The bytes' fingerprint, for entities whose content is not in
24
+ // hand. Files are the whole reason: `files()` sets meta.url on
25
+ // every file entity, so `meta` is never null, so the old
26
+ // "file-only entity" special case (meta == null && content ==
27
+ // null) never fired for a real file — and the fall-through
28
+ // hashed {meta, content} with content null. The result was an
29
+ // inputHash that did not move when an image, video or download
30
+ // changed on disk. The file itself is copied rather than
31
+ // rendered, so nothing looked wrong; what broke was every
32
+ // dependent, whose refClosure dep-hash for that file was frozen
33
+ // and whose skipDecision therefore always said "unchanged".
34
+ //
35
+ // Excluded when content IS present: content is then the
36
+ // authoritative copy of the same bytes, and folding in a
37
+ // checksum computed a different way would make the hash depend
38
+ // on how the checksum happens to be derived.
39
+ checksum: entity.content == null ? entity.checksum ?? null : null,
29
40
  // `inputs` is how a plugin declares bytes that are NOT part of the
30
41
  // entity's own content but that its output depends on. Whatever is
31
42
  // put here participates in the hash, so a change to it invalidates
@@ -41,16 +52,26 @@ export function inputHashOf(entity) {
41
52
  })).digest('hex')
42
53
  }
43
54
 
44
- // Canonical lookup variants for an entity — the same three forms the
55
+ // Canonical lookup variants for an entity — the same four forms the
45
56
  // schemas plugin, refs subscribers, and the catalog's findRef all use
46
57
  // to resolve `$author: '/authors/jane'` against an entity at
47
58
  // `/documents/authors/jane.yml` with `meta.href: '/authors/jane'`.
48
59
  // Pure: synchronous, no I/O.
60
+ //
61
+ // MUST stay in lockstep with `refFilter` below, which is the forward
62
+ // direction of the same relation. `meta.url` used to be missing here
63
+ // while refFilter had it: a `$hero: /hero.txt` ref to a served path
64
+ // (ADR-0011) recorded an edge against `/hero.txt`, but the file entity
65
+ // at `/files/hero.txt` produced keys `['/files/hero.txt',
66
+ // '/files/hero']` — so nothing ever matched the edge and editing the
67
+ // asset invalidated nothing. Every $-ref to an image, video or
68
+ // download was silently non-invalidating.
49
69
  export function lookupKeys(entity) {
50
70
  const id = entity?.id
51
71
  if (!id) return []
52
72
  const keys = [id]
53
73
  if (entity.meta?.href) keys.push(entity.meta.href)
74
+ if (entity.meta?.url) keys.push(entity.meta.url)
54
75
  if (typeof id === 'string') {
55
76
  const stripped = id.replace(/\.[^./]+$/, '')
56
77
  if (stripped !== id) keys.push(stripped)
@@ -59,7 +80,7 @@ export function lookupKeys(entity) {
59
80
  }
60
81
 
61
82
  // Predicate inverse of `lookupKeys`: does `entity` answer to `refValue`
62
- // via any of the three canonical forms? Used by anywhere a per-entity
83
+ // via any of the four canonical forms? Used by anywhere a per-entity
63
84
  // JS test of "does this match the ref" is needed without going through
64
85
  // the catalog (e.g. testing an in-hand entity).
65
86
  //
@@ -71,6 +92,7 @@ export function matchesRef(entity, refValue) {
71
92
  if (!entity || typeof refValue !== 'string') return false
72
93
  if (entity.id === refValue) return true
73
94
  if (entity.meta?.href === refValue) return true
95
+ if (entity.meta?.url === refValue) return true
74
96
  if (typeof entity.id === 'string' && entity.id.replace(/\.[^./]+$/, '') === refValue) return true
75
97
  return false
76
98
  }
@@ -910,6 +932,68 @@ export function useCollection(runtime, name) {
910
932
  }
911
933
  }
912
934
 
935
+ // Write `bytes` to `file`, unless the file already holds exactly those
936
+ // bytes. Returns true if it wrote, false if the file was already correct.
937
+ //
938
+ // Invalidation is deliberately conservative: an entity that merely READ
939
+ // another entity re-renders when that one changes, because the engine
940
+ // cannot know which field was read. That is the right default, and it
941
+ // means renders regularly produce byte-identical output. Writing anyway
942
+ // moves mtime, and three things downstream key off the file rather than
943
+ // its contents:
944
+ //
945
+ // - live reload watches the output folder, so editing one photograph
946
+ // reloaded the browser on pages that had not changed
947
+ // - rsync, `aws s3 sync` and most CDN tools compare size plus mtime,
948
+ // so unchanged pages re-upload
949
+ // - `find out -newer` cannot answer "what did this build change?"
950
+ //
951
+ // Doing it here rather than narrowing the dependency edges fixes every
952
+ // conservative-invalidation case at once, and stays correct as the graph
953
+ // gets more precise instead of becoming redundant.
954
+ //
955
+ // Ordering matters: the size check comes first so the common
956
+ // output-really-changed case never pays for a read, and lstat (not stat)
957
+ // because a destination that is currently a SYMLINK has to be replaced
958
+ // by a real file even when the bytes behind it match — the type of the
959
+ // destination is part of the output, not just its contents.
960
+ export async function writeOutput(file, bytes) {
961
+ // Size first, and WITHOUT materialising a buffer: Buffer.byteLength
962
+ // measures a string in place, while Buffer.from copies it (~250µs for
963
+ // a 1MB page, against ~25µs for the lstat). Since a size mismatch is
964
+ // the common outcome on a build that changed something, the cheap
965
+ // path must not pay for the expensive one.
966
+ const size = Buffer.isBuffer(bytes) ? bytes.length : Buffer.byteLength(bytes)
967
+ let identical = false
968
+ try {
969
+ const info = await lstat(file)
970
+ if (info.isFile() && info.size === size) {
971
+ const existing = await readFile(file)
972
+ identical = Buffer.isBuffer(bytes)
973
+ ? bytes.equals(existing)
974
+ : existing.equals(Buffer.from(bytes))
975
+ }
976
+ } catch (err) {
977
+ // Missing or unreadable — fall through and write. Anything else
978
+ // is a bug in this function, and swallowing it would look
979
+ // exactly like "the file wasn't there": a missing `lstat` import
980
+ // made every comparison fail open, so the skip silently never
981
+ // happened while the tests still passed.
982
+ if (err.code !== 'ENOENT' && err.code !== 'EACCES') throw err
983
+ }
984
+ if (identical) return false
985
+ await mkdir(path.dirname(file), { recursive: true })
986
+ // Unlink first so an existing hard link or symlink at this path is
987
+ // broken rather than written through.
988
+ try {
989
+ await unlink(file)
990
+ } catch { /* not there, or not removable — writeFile will say so */ }
991
+ // Pass the original value through — writeFile encodes a string
992
+ // directly, so converting first would add a copy for nothing.
993
+ await writeFile(file, bytes)
994
+ return true
995
+ }
996
+
913
997
  // ── operating-system and file-manager litter ────────────────────────────
914
998
  //
915
999
  // Exposing a source folder over a network filesystem (mikser-io-webdav) or