mikser-io 9.15.0 → 9.16.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.0",
3
+ "version": "9.16.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/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
@@ -303,26 +323,48 @@ export function createManifest(db) {
303
323
  }
304
324
  continue
305
325
  }
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
326
+ // An edge is checked against every key it could have been
327
+ // hit by: the entity it BOUND to, and the name it asked
328
+ // for. entity.id is always lookupKeys()[0], so the engine's
329
+ // mutation maps are already keyed by id — reading them by
330
+ // targetId needs no change there, and a binding survives
331
+ // the target renaming itself.
332
+ //
333
+ // Both, not the first match, because the two can disagree:
334
+ // the bound entity may have been re-persisted unchanged in
335
+ // the same cycle that a DIFFERENT entity started answering
336
+ // to the same name. Stopping at the binding would compare
337
+ // an unchanged hash and skip, silently ignoring the new
338
+ // claimant. The name key also carries unresolved/forward
339
+ // edges, which is how a link to a not-yet-existing page
340
+ // invalidates once that page appears.
341
+ const keys = [
342
+ ...(entry.targetIds ?? []),
343
+ ...(entry.targetId ? [entry.targetId] : []),
344
+ entry.target,
345
+ ]
346
+ for (const key of keys) {
347
+ if (!mutatedRefs?.has(key)) continue
348
+ // Language scope: if the source has a meta.lang AND the
349
+ // mutation came from a different meta.lang, the ref
350
+ // doesn't actually depend on what changed. A `null` lang
351
+ // in the mutation set means a shared (un-localized)
352
+ // entity touched the same key — that does invalidate
353
+ // language-specific sources because the shared entity
354
+ // is the only variant. Sources without a meta.lang are
355
+ // language-agnostic and accept any mutation lang.
356
+ if (sourceLang) {
357
+ const mutatedLangs = mutatedRefs.get(key)
358
+ if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
359
+ continue
360
+ }
319
361
  }
362
+ if (!entry.hash) return { skip: false, reason: 'ref-changed' }
363
+ const currentHash = currentHashes?.get(key)
364
+ if (currentHash === undefined) continue
365
+ if (currentHash === null) return { skip: false, reason: 'ref-changed' }
366
+ if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
320
367
  }
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
368
  }
327
369
  return { skip: true, reason: 'unchanged' }
328
370
  },
@@ -427,6 +469,7 @@ export function createManifest(db) {
427
469
  edges.push({
428
470
  kind: 'layout',
429
471
  target: entity.layout.id,
472
+ targetId: entity.layout.id,
430
473
  hash: inputHashOf(entity.layout),
431
474
  })
432
475
  }
@@ -436,10 +479,38 @@ export function createManifest(db) {
436
479
  edges.push({
437
480
  kind: 'partial',
438
481
  target,
482
+ targetId: partial ? target : undefined,
439
483
  hash: partial ? inputHashOf(partial) : undefined,
440
484
  })
441
485
  }
442
486
  }
487
+ if (track?.lookups) {
488
+ for (const [target, ids] of track.lookups) {
489
+ // The lookup helper resolved this already and handed
490
+ // over the ids, so the hash comes from the BOUND
491
+ // entity. Hashing findById(target) instead — as this
492
+ // did — silently produced no hash at all whenever the
493
+ // target was an href or url form, because findById is
494
+ // an exact primary-key read: it resolves neither
495
+ // meta.href nor meta.url nor a stripped extension.
496
+ // Every such edge was hashless, so the manifest could
497
+ // not tell "target moved" from "target changed".
498
+ //
499
+ // No hash still means "nothing resolved", and
500
+ // skipDecision re-renders a hashless edge whose target
501
+ // mutated — which is what should happen when a page
502
+ // that was linked-to-but-missing finally appears.
503
+ const targetIds = [...ids]
504
+ const bound = targetIds.length === 1 ? findById(targetIds[0]) : null
505
+ edges.push({
506
+ kind: 'lookup',
507
+ target,
508
+ targetId: targetIds.length === 1 ? targetIds[0] : undefined,
509
+ targetIds: targetIds.length > 1 ? targetIds : undefined,
510
+ hash: bound ? inputHashOf(bound) : undefined,
511
+ })
512
+ }
513
+ }
443
514
  if (track?.queries) {
444
515
  for (const filter of track.queries) {
445
516
  edges.push({ kind: 'query', filter })
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
@@ -187,10 +194,31 @@ export default async ({ entity, options, config, context, state, logger, port, t
187
194
  // state.layouts.sitemap map that was serialized per render
188
195
  // task. Goes through the same WAL-backed read-only handle
189
196
  // every worker shares with the engine writer.
190
- lookupHref: lookupHrefViaDb,
197
+ // Both lookups RECORD what they were asked for, via track.lookup.
198
+ //
199
+ // They read the catalog directly, and until this they told nobody —
200
+ // so nothing knew a page depended on the page it links to. Renaming
201
+ // a target left every page linking to it pointing at a file that no
202
+ // longer existed, on a green build, because manifest.shouldSkip had
203
+ // no edge to check. A sidecar's findEntities() was tracked all along;
204
+ // these two were the asymmetry.
205
+ //
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.
209
+ lookupHref: (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
218
+ },
191
219
  // Resolve a served-entity reference to its deployed URL, absolute
192
220
  // when runtime.options.url is set (ADR-0011).
193
- lookupUrl: (ref, preset) => lookupUrlViaDb(ref, preset, options.url),
221
+ lookupUrl: (ref, preset) => lookupUrlViaDb(ref, preset, options.url, track),
194
222
  content() {
195
223
  return readFileSync(entity.source, { encoding: 'utf8' })
196
224
  },
package/src/track.js CHANGED
@@ -40,8 +40,47 @@ export function filterKey(filter) {
40
40
  // The returned object exposes `partials: Set<string>` and
41
41
  // `queries: Array<filter | null>` directly. Consumers iterate either
42
42
  // shape; both are owned by the track for the lifetime of the run.
43
- export function createTrack({ partial = true, query = true } = {}) {
43
+ export function createTrack({ partial = true, query = true, lookup = true } = {}) {
44
44
  const track = {}
45
+ if (lookup) {
46
+ // Lookups a TEMPLATE made by name: runtime.href('/contacts'),
47
+ // runtime.lookupUrl('/media/clip.mp4'). Both read the catalog
48
+ // directly, and until this existed neither told anyone — so nothing
49
+ // recorded that a page depends on the page it links to.
50
+ //
51
+ // Measured consequence: rename contacts.md to contact-us.md and only
52
+ // the renamed page re-renders. Every page linking to it keeps a href
53
+ // pointing at a file that no longer exists, on a green build.
54
+ //
55
+ // The edge kind is 'lookup', NOT 'ref': mikser_refs divides ownership
56
+ // by kind — indexEntity owns kind='ref' (static frontmatter $-refs)
57
+ // and clears it per source, while replaceDynamic owns everything
58
+ // else. Writing these as 'ref' got them inserted by replaceDynamic
59
+ // and then wiped by the next indexEntity, so the edge existed in
60
+ // the manifest and never in the refs index — recorded, and still
61
+ // never scheduling a re-render.
62
+ //
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()
74
+ track.lookups = lookups
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
+ }
83
+ }
45
84
  if (partial) {
46
85
  const partials = new Set()
47
86
  track.partials = partials
package/src/utils.js CHANGED
@@ -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
  }