mikser-io 9.27.0 → 9.30.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.
@@ -495,13 +495,28 @@ The engine substrate is a single sqlite file at
495
495
  that need their own persistence reach it through these two helpers —
496
496
  never by opening a second file.
497
497
 
498
- ### `registerSchema(name, sqlScript)`
498
+ ### `registerSchema(name, sqlScript, { durable } = {})`
499
499
 
500
500
  Register a CREATE-TABLE-IF-NOT-EXISTS SQL block. All registered schemas
501
501
  are applied during `onLoaded`, after the engine's own tables
502
502
  (`mikser_entities`, `mikser_refs`, `mikser_snapshots`, `mikser_journal`,
503
503
  `mikser_meta`) and before any plugin's `onLoaded` runs.
504
504
 
505
+ **`durable`** (default `false`) — keep these tables when the cache is
506
+ wiped. The engine wipes on a schema-version change (any upgrade) and on a
507
+ config-checksum change (any deploy that edits `mikser.config.js`), because
508
+ per ADR-0002 the files are the source of truth and the database is derived.
509
+ That holds for anything rebuildable and fails for anything that is not: an
510
+ OAuth client registration, a refresh token, a received form submission
511
+ cannot be recreated from the working folder, and losing them is silent —
512
+ the first sign is a user being asked to sign in again after an unrelated
513
+ deploy.
514
+
515
+ Mark those `durable: true` and the wipe drops every other table instead of
516
+ deleting the database file. Leave it off for anything you can rebuild:
517
+ stale derived rows surviving an upgrade is the failure the wipe exists to
518
+ prevent.
519
+
505
520
  ```js
506
521
  registerSchema('my_plugin_data', `
507
522
  CREATE TABLE IF NOT EXISTS my_plugin_data (
@@ -19,6 +19,9 @@ engine source, the entry point is missing and belongs on this page.
19
19
  | What depends on this entity? | [`runtime.refs`](#runtimerefs) |
20
20
  | Which layout claimed this document, and why that one? | [`layouts.inspect()`](#layoutsinspect) |
21
21
  | Why does this page's output look stale? | [`runtime.manifest`](#runtimemanifest) |
22
+ | Two files seem to fight over one output | [`--explain`](#--explain-entity), [`--verify`](#--verify) |
23
+ | Which source file produced this built output? | [`runtime.manifest`](#runtimemanifest), `mikser_which` |
24
+ | What would break if I changed this file? | [`runtime.manifest`](#runtimemanifest) `affectedBy` |
22
25
  | Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
23
26
 
24
27
  ## Command line
@@ -56,6 +59,20 @@ rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved si
56
59
  A snapshot written before per-input recording says so rather than
57
60
  guessing.
58
61
 
62
+ When another entity renders to the same destination, that leads the
63
+ verdict and the hash reasoning follows it:
64
+
65
+ ```
66
+ CONTESTED — /bg/index.html is also claimed by /documents/bg/index.md.
67
+ Whichever renders last wins and the other output is discarded; the
68
+ input-hash reasoning below describes this entity only.
69
+ ```
70
+
71
+ The full list is under `competingDestinations` in `--json` form. The
72
+ hash reasoning on its own is true and useless here: "would be SKIPPED,
73
+ input hash unchanged" is correct about the entity you asked about and
74
+ silent about its output being overwritten by someone else's.
75
+
59
76
  A destination whose **last render attempt threw** reads as such, rather
60
77
  than as current:
61
78
 
@@ -128,6 +145,11 @@ Five buckets, and the distinction between them is the point:
128
145
  | `errors` | the render ran and **threw** — with `id`, `destination`, `error`, `layout` |
129
146
  | `gated` | a count — the source was unchanged, so no render was ever scheduled |
130
147
 
148
+ Each report also carries `cycleId`, `startedAt` and `finishedAt`. Under
149
+ `--watch` two consecutive reports are otherwise indistinguishable, so
150
+ "is this my edit's cycle or the one before it" has no answer without the
151
+ id. A cold build is cycle 1.
152
+
131
153
  A failed render appears in `errors` and **not** in `rendered`: that bucket
132
154
  means the output moved, and a throw writes nothing. The previous good bytes
133
155
  stay on disk, which is what makes a failed render survivable — and also
@@ -227,6 +249,14 @@ instead of building. Four categories, and the split matters:
227
249
  | `Mismatched` | the file's bytes differ from the recorded `outputHash` | error |
228
250
  | `No hash` | a snapshot with no recorded hash — nothing to compare | warning |
229
251
  | `Orphan` | a file on disk that no snapshot claims | warning |
252
+ | `Collision` | two or more entities record snapshots for the same destination | warning |
253
+
254
+ `Collision` is the one that does not show up as drift. Every render
255
+ hashes the file *after* writing it, so when two entities write the same
256
+ path the loser records the winner's bytes and both snapshots agree with
257
+ disk — missing, mismatched and orphaned are all empty and the output is
258
+ still wrong. It is reported by shape rather than by comparison, which is
259
+ why it is here and not in the three categories above.
230
260
 
231
261
  Exit codes make it usable as a CI gate directly: `2` if anything is
232
262
  missing or mismatched, `1` if only warnings, `0` when clean, and `2` when
@@ -269,7 +299,16 @@ an SDK, or an agent speaking MCP actually has.
269
299
 
270
300
  **MCP** — `mikser_explain`, `mikser_build_report`, `mikser_verify`,
271
301
  alongside the existing `mikser_refs_*`, `mikser_layouts_inspect` and the
272
- `mikser://logs/recent` resource.
302
+ `mikser://logs/recent` resource. Four more answer the questions a shell
303
+ would otherwise be needed for: `mikser_search` finds a string across
304
+ entity meta, source files and — with `in: ["output"]` — the built files,
305
+ reporting occurrences per destination; `mikser_read_output` reads the
306
+ bytes currently on disk for a destination, which is a different question
307
+ from what the catalog or the manifest says should be there;
308
+ `mikser_which` goes the other way, from a built destination back to the
309
+ source that produced it and the line that defines a given selector; and
310
+ `mikser_update_entity({ dryRun: true })` reports the blast radius of an
311
+ edit before making it.
273
312
 
274
313
  **REST** — on the `api` plugin, gated on their own `diagnostics`
275
314
  operation:
@@ -398,7 +437,11 @@ What was rendered and whether it needs redoing.
398
437
  | `skipDecision(entity, …)` | `{ skip, reason }` — the same reason `--json` reports |
399
438
  | `recordedHashes()` | the dep-hashes dependents last saw |
400
439
  | `queryAffected(mutated)` | which query-dependent snapshots this mutation hits |
401
- | `verify({outputFolder})` | `{ missing, mismatched, unverifiable, orphaned }` what `--verify` reports; pure, no mutations |
440
+ | `snapshotsAt(destination)` | every snapshot claiming a destinationthe reverse of `snapshotsFor`, and the way back from a built file to what produced it |
441
+ | `affectedBy(entity)` | which destinations would re-render if this entity changed, each with the same `reason` the build report uses |
442
+ | `verify({outputFolder})` | `{ verdict, missing, mismatched, unverifiable, orphaned, collisions }` — what `--verify` reports; pure, no mutations |
443
+ | `collisions()` | destinations claimed by more than one entity, with the ids claiming each |
444
+ | `writerOf(destination, outputHash)` | which of several claimants wrote the bytes now on disk, when the hashes can tell them apart |
402
445
  | `size()` | snapshot count |
403
446
 
404
447
  `snapshotsFor(id)` exists because an entity can render to several
@@ -406,6 +449,14 @@ destinations and a caller asking "what happened to this?" does not know
406
449
  them in advance — which is exactly the position you are in when a page
407
450
  did not change and you want to know why.
408
451
 
452
+ `affectedBy(entity)` answers the same question one step earlier: *before*
453
+ editing a shared file, which outputs does this reach? It runs the real
454
+ `skipDecision` against each candidate rather than reimplementing the
455
+ rule, so the preview and the cycle it previews cannot disagree. What it
456
+ cannot model is how the entity's own frontmatter would change — that is
457
+ parsed during import, so an edit that moves `meta.layout` moves the
458
+ destination too, and this does not see it.
459
+
409
460
  ### `layouts.inspect()`
410
461
 
411
462
  Exposed by `mikser-io-layouts` at `runtime.options.layouts.inspect(id)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.27.0",
3
+ "version": "9.30.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/auth.js CHANGED
@@ -112,6 +112,11 @@ export function reachabilityOf({ auth, token, allowRemote } = {}) {
112
112
  // loopback, or allowRemote → allow
113
113
  // otherwise → 403
114
114
  //
115
+ // A verifier may refine the "presented, invalid" case through an optional
116
+ // `rejectionFor(req)` returning `{ status, code, description }` — see the
117
+ // call site. It can only narrow a denial that already happened; there is no
118
+ // return value from it that turns a rejection into an acceptance.
119
+ //
115
120
  // `trustLoopback: true` restores the older mikser behaviour where a
116
121
  // token-gated endpoint stayed open to localhost. It exists so the api and
117
122
  // mcp plugins can keep their documented semantics for a plain `token:`
@@ -123,10 +128,30 @@ export async function authorize(req, verifier, { allowRemote = false, trustLoopb
123
128
  const result = await verifier.verify(req)
124
129
  if (result) return { ok: true, principal: result }
125
130
  if (result === false) {
126
- return { ok: false, status: 401, reason: 'invalid',
127
- error: 'Invalid credential' }
131
+ // A rejected credential is not one thing. An EXPIRED token means
132
+ // "exchange your refresh token and retry" — a client does that
133
+ // silently. A token whose subject lacks the capability means
134
+ // "refreshing will not help", and a client that refreshes on it
135
+ // loops. Told apart only by the verifier, which is the only thing
136
+ // that looked at the credential, so it gets to refine the answer.
137
+ //
138
+ // Absent (every verifier before this existed), the answer is
139
+ // today's: 401 invalid_token, which is right for the common case
140
+ // and is what a client needs in order to refresh at all.
141
+ const refined = verifier.rejectionFor?.(req)
142
+ return {
143
+ ok: false,
144
+ status: refined?.status ?? 401,
145
+ reason: 'invalid',
146
+ code: refined?.code ?? 'invalid_token',
147
+ description: refined?.description,
148
+ error: refined?.description ?? 'Invalid credential',
149
+ }
128
150
  }
129
- // Nothing presented.
151
+ // Nothing presented. No `code`: RFC 6750 §3.1 says a challenge to a
152
+ // request that carried no credential omits `error` entirely, and the
153
+ // omission is the signal — it is how a client tells "you have never
154
+ // authenticated here" from "the token you hold went stale".
130
155
  if (trustLoopback && local) return { ok: true, principal: { subject: 'loopback' } }
131
156
  return { ok: false, status: 401, reason: 'missing',
132
157
  error: 'Authentication required' }
@@ -153,7 +178,12 @@ export function requireAuth(verifier, options = {}) {
153
178
  req.principal = outcome.principal
154
179
  return next()
155
180
  }
156
- if (outcome.status === 401) verifier?.challenge?.(req, res)
181
+ // 403 carries a challenge too: RFC 6750 §3.1 puts insufficient_scope
182
+ // there, and a client that only reads the header on a 401 is exactly
183
+ // the client that cannot tell the two apart.
184
+ if (outcome.status === 401 || outcome.status === 403) {
185
+ verifier?.challenge?.(req, res, outcome)
186
+ }
157
187
  res.status(outcome.status).json({ error: outcome.error })
158
188
  }
159
189
  }
@@ -202,12 +232,24 @@ export function anyOf(...verifiers) {
202
232
  return rejected ? false : null
203
233
  },
204
234
 
235
+ // Whichever member actually judged the credential gets to say why it
236
+ // failed. Without forwarding this, composing a static token with an
237
+ // OAuth verifier silently downgrades every expiry to a bare 401 and
238
+ // the refresh signal is lost precisely on the surfaces that have one.
239
+ rejectionFor(req) {
240
+ for (const verifier of list) {
241
+ const refined = verifier.rejectionFor?.(req)
242
+ if (refined) return refined
243
+ }
244
+ return undefined
245
+ },
246
+
205
247
  // Challenge with the verifier that can actually be satisfied
206
248
  // interactively — pointing a browser at "Bearer" when the real
207
249
  // option is OAuth discovery helps nobody.
208
- challenge(req, res) {
250
+ challenge(req, res, outcome) {
209
251
  const chooser = discovering ?? list.find(v => v.challenge)
210
- chooser?.challenge?.(req, res)
252
+ chooser?.challenge?.(req, res, outcome)
211
253
  },
212
254
  }
213
255
  }
@@ -103,7 +103,26 @@ let db = null
103
103
  // duplicate detection. Same name twice = the later registration wins
104
104
  // (with a warning). Convention: `<owner>` matching the table prefix
105
105
  // (`catalog`, `manifest`, `vector`, etc.).
106
- export function registerSchema(name, sqlScript) {
106
+ // Table names a schema script creates. Used to decide what a cache wipe
107
+ // must leave alone — the registry knows schema NAMES, and the wipe works in
108
+ // tables.
109
+ // A registered schema is either { sql, durable } or, from an external
110
+ // caller of createSqliteDatabase, the bare SQL string that shape replaced.
111
+ // Both are supported: the argument is public API and a plugin or test
112
+ // passing a Map of strings predates the durability flag.
113
+ function schemaEntry(value) {
114
+ return typeof value === 'string' ? { sql: value, durable: false } : value
115
+ }
116
+
117
+ function tableNamesFrom(sqlScript) {
118
+ const names = []
119
+ const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"'\[]?([A-Za-z_][\w$]*)/gi
120
+ let m
121
+ while ((m = re.exec(sqlScript))) names.push(m[1])
122
+ return names
123
+ }
124
+
125
+ export function registerSchema(name, sqlScript, { durable = false } = {}) {
107
126
  if (typeof name !== 'string' || !name.length) {
108
127
  throw new Error('registerSchema: name must be a non-empty string')
109
128
  }
@@ -117,7 +136,7 @@ export function registerSchema(name, sqlScript) {
117
136
  useLogger().warn('registerSchema: "%s" already registered, overwriting', name)
118
137
  } catch { /* logger may not exist yet at module-import time */ }
119
138
  }
120
- schemas.set(name, sqlScript)
139
+ schemas.set(name, { sql: sqlScript, durable })
121
140
 
122
141
  // Lazy-apply: if the database is already open, run the script
123
142
  // against the live handle. Idempotent CREATE statements make this
@@ -295,15 +314,50 @@ export function createSqliteDatabase({
295
314
  recorded, version,
296
315
  )
297
316
  }
298
- handle.close()
299
- handle = null
317
+ // What must survive. A wipe exists because the cache is
318
+ // DERIVED — ADR-0002, the files are the source of truth, so
319
+ // throwing it away costs a rebuild and nothing else. That
320
+ // reasoning does not reach a table holding data no file can
321
+ // reproduce: an OAuth client registration, a refresh token, a
322
+ // form submission. Deleting the database file takes those with
323
+ // it, and the operator's first sign that it happened is being
324
+ // asked to authorize again.
325
+ //
326
+ // So a schema registered `durable` is kept and everything else
327
+ // goes. mikser_meta stays too — its stamps are rewritten a few
328
+ // lines down, and dropping it would only mean recreating it.
329
+ const durableTables = new Set(['mikser_meta'])
330
+ for (const value of schemas.values()) {
331
+ const { sql, durable } = schemaEntry(value)
332
+ if (durable) for (const t of tableNamesFrom(sql)) durableTables.add(t)
333
+ }
334
+
335
+ if (durableTables.size > 1 && dbPath !== ':memory:') {
336
+ // Drop table by table rather than unlinking, so the durable
337
+ // ones keep their rows. Foreign keys off for the duration:
338
+ // a cache table may reference another and drop order here is
339
+ // whatever sqlite_master returns.
340
+ const tables = handle
341
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
342
+ .all()
343
+ handle.exec('PRAGMA foreign_keys = OFF')
344
+ for (const { name: table } of tables) {
345
+ if (durableTables.has(table)) continue
346
+ handle.exec(`DROP TABLE IF EXISTS "${table}"`)
347
+ }
348
+ handle.exec('PRAGMA foreign_keys = ON')
349
+ logger?.debug('Cache wiped, %d durable table(s) preserved', durableTables.size - 1)
350
+ } else {
351
+ handle.close()
352
+ handle = null
300
353
 
301
- if (dbPath !== ':memory:') {
302
- // sqlite WAL leaves -wal and -shm sidecar files. Remove
303
- // them along with the main file so the next open starts
304
- // from a guaranteed-clean slate.
305
- for (const suffix of ['', '-wal', '-shm']) {
306
- try { unlinkSync(dbPath + suffix) } catch { /* file may not exist */ }
354
+ if (dbPath !== ':memory:') {
355
+ // sqlite WAL leaves -wal and -shm sidecar files. Remove
356
+ // them along with the main file so the next open starts
357
+ // from a guaranteed-clean slate.
358
+ for (const suffix of ['', '-wal', '-shm']) {
359
+ try { unlinkSync(dbPath + suffix) } catch { /* file may not exist */ }
360
+ }
307
361
  }
308
362
  }
309
363
 
@@ -311,8 +365,10 @@ export function createSqliteDatabase({
311
365
  // which is the right shape for a config change too: what the
312
366
  // provisioners see is an empty-state database either way.
313
367
  upgradedFromVersion = recorded ?? 'config'
314
- handle = new Database(dbPath)
315
- setupConnection()
368
+ if (!handle) {
369
+ handle = new Database(dbPath)
370
+ setupConnection()
371
+ }
316
372
  }
317
373
  const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
318
374
  stmtStamp.run('schema_version', version)
@@ -354,7 +410,8 @@ export function createSqliteDatabase({
354
410
 
355
411
  // Apply each subsystem's registered schema script. Idempotent
356
412
  // CREATE statements mean replay-safe across opens.
357
- for (const [name, sqlScript] of schemas) {
413
+ for (const [name, value] of schemas) {
414
+ const { sql: sqlScript } = schemaEntry(value)
358
415
  try {
359
416
  handle.exec(sqlScript)
360
417
  logger?.debug('Database schema applied: %s', name)
package/src/engine.js CHANGED
@@ -10,7 +10,7 @@ import { useJournal, updateEntry } from './journal.js'
10
10
  import { globby } from 'globby'
11
11
  import { OPERATION, TASKS } from './constants.js'
12
12
  import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
13
- import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport } from './report.js'
13
+ import { reportRendered, reportSkipped, reportError, reportWarning, renderErrorCount, emitReport, finishCycle } from './report.js'
14
14
  import render from './render.js'
15
15
  import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
16
16
  import map from 'p-map'
@@ -253,35 +253,40 @@ export async function setup(options) {
253
253
  logger.error('Verify: no manifest available — nothing to check against')
254
254
  process.exit(2)
255
255
  }
256
- const diff = await runtime.manifest.verify()
257
- const { missing, mismatched, unverifiable, orphaned } = diff
258
-
256
+ const { verdict, missing, mismatched, unverifiable, orphaned, collisions } =
257
+ await runtime.manifest.verify()
259
258
  const total = runtime.manifest.size()
260
- const errors = missing.length + mismatched.length
261
- const warnings = orphaned.length + unverifiable.length
262
259
 
263
- for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
264
- for (const e of mismatched) logger.error('Mismatched: %s (entity %s)', e.destination, e.id)
260
+ for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
261
+ for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
262
+ e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
265
263
  for (const e of unverifiable) logger.warn('No hash: %s (entity %s)', e.destination, e.id)
266
- for (const e of orphaned) logger.warn('Orphan: %s', e.path)
264
+ for (const e of orphaned) logger.warn('Orphan: %s', e.path)
265
+ // Named per destination: "two entities write here" is only
266
+ // actionable if you know which two.
267
+ for (const c of collisions) logger.warn('Collision: %s ← %s', c.destination, c.entities.join(', '))
267
268
 
268
269
  // Level picked from the verdict, because the level IS the marker
269
270
  // in pino-pretty's messageFormat: notice renders 🟢, warn 🟡,
270
271
  // error 🔴. A fixed `notice` prints a green tick next to the word
271
272
  // FAIL, which reads as success at a glance even though the exit
272
273
  // code is right.
273
- const verdict = errors > 0 ? 'FAIL' : (warnings > 0 ? 'WARN' : 'OK')
274
- const report = errors > 0 ? logger.error : (warnings > 0 ? logger.warn : logger.notice)
274
+ const report = verdict === 'FAIL' ? logger.error : verdict === 'WARN' ? logger.warn : logger.notice
275
275
  report.call(logger,
276
- 'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned',
277
- verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length)
278
- process.exit(errors > 0 ? 2 : (warnings > 0 ? 1 : 0))
276
+ 'Verify %s: %d snapshots, %d missing, %d mismatched, %d unverifiable, %d orphaned, %d collisions',
277
+ verdict, total, missing.length, mismatched.length, unverifiable.length, orphaned.length, collisions.length)
278
+ process.exit(verdict === 'FAIL' ? 2 : verdict === 'WARN' ? 1 : 0)
279
279
  }
280
280
  })
281
281
 
282
282
  onRender(async (signal) => {
283
283
  const logger = useLogger()
284
284
  const renderJobs = new Set()
285
+ // destination → the entity ids that actually rendered to it this
286
+ // cycle. Recorded past the skip gate rather than alongside renderJobs
287
+ // so it holds renders that ran, which is what makes "one overwrote
288
+ // the other" a true statement rather than a guess about two claims.
289
+ const renderedTo = new Map()
285
290
 
286
291
  // Collect this cycle's mutated entity ids/hrefs/entities so the
287
292
  // manifest skip check can re-render anything whose dependencies
@@ -374,6 +379,10 @@ export async function setup(options) {
374
379
  logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
375
380
  return
376
381
  }
382
+ if (entity.destination) {
383
+ if (!renderedTo.has(entity.destination)) renderedTo.set(entity.destination, new Set())
384
+ renderedTo.get(entity.destination).add(entity.id)
385
+ }
377
386
  // Reported on the way OUT, not here: `rendered` means the
378
387
  // output moved, and a render that throws writes nothing. The
379
388
  // decision is carried down to the success path so the reason
@@ -575,6 +584,27 @@ export async function setup(options) {
575
584
  renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped - failed)
576
585
  skipped && logger.info('Manifest skipped: %d', skipped)
577
586
  failed && logger.error('Render errors: %d', failed)
587
+
588
+ // Two entities writing one destination in the same cycle: one
589
+ // silently overwrote the other, and every other signal reads clean.
590
+ // Reported per cycle rather than only by --verify because this is
591
+ // the moment it happened, and because a build that discards half its
592
+ // output must not report warnings: 0.
593
+ //
594
+ // Derived from the destinations THIS cycle rendered, so an
595
+ // established collision the operator already knows about does not
596
+ // re-warn on every unrelated build; --verify is where the standing
597
+ // state lives.
598
+ for (const [destination, ids] of renderedTo) {
599
+ if (ids.size < 2) continue
600
+ const entities = [...ids].sort()
601
+ reportWarning('destination-collision', { destination, entities })
602
+ logger.warn(
603
+ 'Destination collision: %s written by %d entities in this cycle (%s). '
604
+ + 'One overwrote the other — whichever rendered last wins.',
605
+ destination, entities.length, entities.join(', '),
606
+ )
607
+ }
578
608
  })
579
609
 
580
610
  onBeforePostprocess(async (signal) => {
@@ -801,6 +831,11 @@ export async function setup(options) {
801
831
  }
802
832
  }
803
833
  }
834
+ // Close the cycle: stamp it and file it in the history, so a caller
835
+ // that asked "tell me about cycle N" gets an answer after N ends
836
+ // rather than only while it is the current one.
837
+ finishCycle()
838
+
804
839
  // A cycle with failed renders is not a completed build, and the word
805
840
  // people read is this one.
806
841
  const failed = renderErrorCount()
package/src/explain.js CHANGED
@@ -85,6 +85,17 @@ function queryCount(counts, filter) {
85
85
  return { matched: hit.count, ...(hit.count === 1 && hit.sample ? { sample: hit.sample } : {}) }
86
86
  }
87
87
 
88
+ // A destination two entities claim outranks anything the hashes say. The
89
+ // hash reasoning would be true AND useless: "would be SKIPPED, input hash
90
+ // unchanged" is correct about this entity and silent about the fact that
91
+ // its output is being overwritten by someone else's.
92
+ function contestedVerdict(competing) {
93
+ if (!competing.length) return null
94
+ const parts = competing.map(c => `${c.destination} is also claimed by ${c.entities.join(', ')}`)
95
+ return `CONTESTED — ${parts.join('; ')}. Whichever renders last wins and the other output is discarded; `
96
+ + 'the input-hash reasoning below describes this entity only.'
97
+ }
98
+
88
99
  // The verdict line names what moved when it can. That line is the one
89
100
  // people read, so "the input hash differs" there is the answer stopping one
90
101
  // step short of useful.
@@ -252,7 +263,8 @@ export async function explain(reference) {
252
263
  }),
253
264
  })),
254
265
  // What a plain build would do next, stated plainly.
255
- verdict: source?.error === 'file is gone'
266
+ verdict: contestedVerdict(competingFor(snapshots, entity.id))
267
+ ?? (source?.error === 'file is gone'
256
268
  ? 'source file is gone — a build would DELETE this entity and unlink its output'
257
269
  : source?.differs
258
270
  ? 'source differs from the catalog — a build would re-import it first, then re-render. '
@@ -264,11 +276,32 @@ export async function explain(reference) {
264
276
  + `(${failedSnapshots[0].error})`
265
277
  : snapshots.some(s => s.inputHash !== currentHash)
266
278
  ? renderVerdict(snapshots, currentHash, currentParts)
267
- : 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
279
+ : 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.'),
268
280
  lookupKeys: lookupKeys(entity),
281
+ // Other entities rendering to the same path as this one.
282
+ //
283
+ // The failure this makes visible: an empty stub and a real page both
284
+ // claiming /bg/index.html. Whichever renders last wins, the other's
285
+ // output is discarded, and nothing else says so — not the build
286
+ // (green), not verify (each render hashes the file after writing, so
287
+ // the loser records the winner's bytes and both snapshots agree with
288
+ // disk), and not this report, which showed the destination and a
289
+ // clean "would be SKIPPED".
290
+ competingDestinations: competingFor(snapshots, entity.id),
269
291
  }
270
292
  }
271
293
 
294
+ // Entities other than `id` whose snapshots claim the same destinations.
295
+ function competingFor(snapshots, id) {
296
+ const mine = new Set(snapshots.map(s => s.destination).filter(Boolean))
297
+ if (!mine.size) return []
298
+ const all = runtime.manifest?.collisions?.() ?? []
299
+ return all
300
+ .filter(c => mine.has(c.destination))
301
+ .map(c => ({ destination: c.destination, entities: c.entities.filter(e => e !== id) }))
302
+ .filter(c => c.entities.length)
303
+ }
304
+
272
305
  // Human-readable rendering. Deliberately aligned columns rather than prose:
273
306
  // the point is to be scanned, and to be diffable between two runs.
274
307
  export function formatExplain(report) {
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, inputPartsOf, diffInputParts } from './utils.js'
59
+ import { extractRefs, inputHashOf, inputPartsOf, diffInputParts, lookupKeys } 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'
@@ -284,6 +284,25 @@ async function hashOutputFile(destination) {
284
284
  export function createManifest(db) {
285
285
  if (!db) throw new Error('createManifest: db is required')
286
286
 
287
+ const stmtCollisions = db.prepare(`
288
+ SELECT destination, count(*) AS n, group_concat(id) AS ids
289
+ FROM mikser_snapshots
290
+ GROUP BY destination HAVING n > 1
291
+ `)
292
+ const stmtClaimants = db.prepare(`
293
+ SELECT id, outputHash FROM mikser_snapshots WHERE destination = ?
294
+ `)
295
+ const stmtSelectByDestination = db.prepare(`
296
+ SELECT id FROM mikser_snapshots WHERE destination = ?
297
+ `)
298
+ const stmtLookupByDestination = db.prepare(`
299
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
300
+ FROM mikser_snapshots WHERE destination = ?
301
+ `)
302
+ const stmtDeleteByDestination = db.prepare(`
303
+ DELETE FROM mikser_snapshots WHERE destination = ?
304
+ `)
305
+
287
306
  const stmtRecordFailure = db.prepare(`
288
307
  INSERT INTO mikser_failures
289
308
  (id, destination, error, context, firstFailedAt, lastFailedAt, attempts)
@@ -377,6 +396,28 @@ export function createManifest(db) {
377
396
  WHERE refClosure LIKE '%"kind":"query"%'
378
397
  `)
379
398
 
399
+ // Snapshots holding a non-query edge that names any of the given keys,
400
+ // by the name asked for OR by the entity it bound to. Both, for the same
401
+ // reason skipDecision reads both — a name survives a rename only through
402
+ // the binding, and a forward edge to a page that does not exist yet has
403
+ // only the name.
404
+ //
405
+ // A prefilter, not the answer: it narrows a corpus-wide walk to the
406
+ // handful of snapshots that could possibly care, and the real
407
+ // skipDecision then judges each one.
408
+ const edgeCandidates = (keys) => {
409
+ if (!keys.length) return []
410
+ const holes = keys.map(() => '?').join(',')
411
+ return db.prepare(`
412
+ SELECT DISTINCT s.id AS id, s.destination AS destination
413
+ FROM mikser_snapshots s, json_each(s.refClosure) j
414
+ WHERE s.refClosure IS NOT NULL
415
+ AND json_extract(j.value, '$.kind') != 'query'
416
+ AND (json_extract(j.value, '$.target') IN (${holes})
417
+ OR json_extract(j.value, '$.targetId') IN (${holes}))
418
+ `).all(...keys, ...keys)
419
+ }
420
+
380
421
  const manifest = {
381
422
  // Look up a previously-recorded entry by entity (or by an
382
423
  // object with `{id, destination}`). Returns the snapshot, or
@@ -399,6 +440,78 @@ export function createManifest(db) {
399
440
  return stmtLookupById.all(id).map(rowToSnap)
400
441
  },
401
442
 
443
+ // Every snapshot that claims a destination — the reverse of
444
+ // snapshotsFor, and the entry point for "what produced this file?".
445
+ // More than one means a collision; see collisions().
446
+ snapshotsAt(destination) {
447
+ if (!destination) return []
448
+ return stmtLookupByDestination.all(destination).map(rowToSnap)
449
+ },
450
+
451
+ // Which destinations would re-render if this entity changed.
452
+ //
453
+ // Answered by running the REAL skipDecision against each candidate,
454
+ // with the mutation maps the render loop would build for exactly this
455
+ // one entity. A second implementation of the invalidation rule would
456
+ // be a preview that disagrees with the cycle it is previewing, which
457
+ // is worse than no preview: it would be trusted.
458
+ //
459
+ // What it cannot model, and says so at its caller: how the entity's
460
+ // OWN meta would change. Frontmatter is parsed during import, not
461
+ // here, so a change that alters meta.layout (and therefore the
462
+ // destination itself) is outside what this can see. Its own snapshots
463
+ // are reported as affected regardless, which is the safe direction.
464
+ affectedBy(entity) {
465
+ if (!entity?.id) return []
466
+ const lang = entity?.meta?.lang ?? null
467
+ const hash = inputHashOf(entity)
468
+ const keys = lookupKeys(entity)
469
+ const mutatedRefs = new Map(keys.map(key => [key, new Set([lang])]))
470
+ const currentHashes = new Map(keys.map(key => [key, hash]))
471
+ const mutatedEntities = new Map([[entity.id, entity]])
472
+
473
+ // Three ways a snapshot can care, unioned before judging so a
474
+ // snapshot reachable by two of them is judged once.
475
+ const candidates = new Map()
476
+ const consider = (id, destination) => {
477
+ if (!id || !destination) return
478
+ candidates.set(`${id}\u0000${destination}`, { id, destination })
479
+ }
480
+ for (const snap of this.snapshotsFor(entity.id)) consider(snap.id, snap.destination)
481
+ for (const row of edgeCandidates(keys)) consider(row.id, row.destination)
482
+ for (const id of this.queryAffected(mutatedEntities)) {
483
+ for (const snap of this.snapshotsFor(id)) consider(snap.id, snap.destination)
484
+ }
485
+
486
+ const affected = []
487
+ for (const { id, destination } of candidates.values()) {
488
+ // Its own renders: the premise of the question is that this
489
+ // entity changed, so asking skipDecision — which compares the
490
+ // hash of the entity as it stands NOW — would answer
491
+ // "unchanged" and hide the one destination the caller is
492
+ // certainly touching.
493
+ if (id === entity.id) {
494
+ affected.push({ id, destination, reason: 'inputs-changed', why: 'this entity\'s own render' })
495
+ continue
496
+ }
497
+ const dependent = findById(id)
498
+ if (!dependent) continue
499
+ const decision = this.skipDecision(
500
+ { ...dependent, destination }, mutatedRefs, currentHashes, mutatedEntities)
501
+ if (decision.skip) continue
502
+ // The same provenance the build report carries. A bare list of
503
+ // destinations answers "how many" and not "why this one",
504
+ // which is the half that makes it checkable.
505
+ affected.push({
506
+ id, destination, reason: decision.reason,
507
+ ...(decision.changed?.length ? { changed: decision.changed } : {}),
508
+ ...(decision.matched ? { matched: decision.matched } : {}),
509
+ ...(decision.dependency ? { dependency: decision.dependency } : {}),
510
+ })
511
+ }
512
+ return affected
513
+ },
514
+
402
515
  // Should this render be skipped? See the original docstring in
403
516
  // the prior NDJSON-backed implementation — logic is unchanged,
404
517
  // backing storage is the only thing that changed.
@@ -610,6 +723,37 @@ export function createManifest(db) {
610
723
  stmtClearFailure.run(entity.id, entity.destination)
611
724
  },
612
725
 
726
+ // Destinations claimed by more than one entity.
727
+ //
728
+ // Two entities rendering to one path is always a bug — one silently
729
+ // overwrites the other — and it is invisible to every other check.
730
+ // Not to `verify`'s hash comparison in particular: each render
731
+ // records the hash of the file AFTER it wrote, so with concurrent
732
+ // renders the loser reads the winner's bytes and both snapshots
733
+ // agree with disk. Measured on the case this exists for: an empty
734
+ // stub and a real homepage both claiming /bg/index.html recorded the
735
+ // SAME outputHash, and verify reported OK.
736
+ //
737
+ // So it is detected structurally rather than by content: the
738
+ // manifest already knows both claimants because a snapshot is keyed
739
+ // (id, destination).
740
+ collisions() {
741
+ return stmtCollisions.all().map(row => ({
742
+ destination: row.destination,
743
+ entities: String(row.ids).split(',').filter(Boolean).sort(),
744
+ }))
745
+ },
746
+
747
+ // Which entity's recorded bytes are the ones on disk right now.
748
+ // Answers "who wrote this?" for a destination several entities
749
+ // claim, which is the question a mismatch leaves open.
750
+ writerOf(destination, outputHash) {
751
+ if (!destination || !outputHash) return null
752
+ const rows = stmtClaimants.all(destination)
753
+ const match = rows.find(r => r.outputHash === outputHash)
754
+ return match?.id ?? null
755
+ },
756
+
613
757
  // Every recorded failure for an entity, across destinations.
614
758
  failuresFor(id) {
615
759
  return id ? stmtFailuresFor.all(id) : []
@@ -821,8 +965,18 @@ export function createManifest(db) {
821
965
  }
822
966
  try {
823
967
  const buf = await readFile(filePath)
824
- if (sha1(buf) !== snap.outputHash) {
825
- mismatched.push({ id: snap.id, destination: snap.destination })
968
+ const actual = sha1(buf)
969
+ if (actual !== snap.outputHash) {
970
+ // Name who DID write the bytes that are there, when a
971
+ // sibling snapshot for the same destination matches
972
+ // them. "Mismatched" alone leaves the reader to work
973
+ // out whether the file was edited by hand or lost a
974
+ // race with another entity claiming the same path.
975
+ mismatched.push({
976
+ id: snap.id,
977
+ destination: snap.destination,
978
+ writtenBy: this.writerOf(snap.destination, actual),
979
+ })
826
980
  }
827
981
  } catch {
828
982
  missing.push({ id: snap.id, destination: snap.destination })
@@ -839,7 +993,29 @@ export function createManifest(db) {
839
993
  if (claimed.has(rel)) continue
840
994
  orphaned.push({ path: rel })
841
995
  }
842
- return { missing, mismatched, unverifiable, orphaned }
996
+ // Reported alongside, not as a mismatch: two entities claiming one
997
+ // destination usually produces NO mismatch at all, because each
998
+ // render hashes the file after writing it and the loser reads the
999
+ // winner's bytes. Without this the whole situation is silent.
1000
+ const collisions = this.collisions()
1001
+ // One verdict, computed here, because three callers report it —
1002
+ // the CLI's exit code, the api route and the MCP tool — and three
1003
+ // copies of the rule would drift.
1004
+ //
1005
+ // A collision is a WARNING, not a failure: nothing is missing or
1006
+ // corrupt, the bytes on disk are some entity's real render. What
1007
+ // is wrong is that another entity's output was discarded, which
1008
+ // the reader has to be told about but which does not mean the
1009
+ // deploy is broken in the way a missing or altered file does.
1010
+ // It is also pre-existing on any site that already has one, so
1011
+ // failing the gate outright would break pipelines on upgrade for
1012
+ // a condition that was always there.
1013
+ const errors = missing.length + mismatched.length
1014
+ const warnings = orphaned.length + unverifiable.length + collisions.length
1015
+ return {
1016
+ verdict: errors > 0 ? 'FAIL' : warnings > 0 ? 'WARN' : 'OK',
1017
+ missing, mismatched, unverifiable, orphaned, collisions,
1018
+ }
843
1019
  },
844
1020
 
845
1021
  size() {
@@ -852,6 +1028,8 @@ export function createManifest(db) {
852
1028
  _stmtSelectByIdOrParent: stmtSelectByIdOrParent,
853
1029
  _stmtDeleteByIdOrParent: stmtDeleteByIdOrParent,
854
1030
  _stmtSelectByParent: stmtSelectByParent,
1031
+ _stmtSelectByDestination: stmtSelectByDestination,
1032
+ _stmtDeleteByDestination: stmtDeleteByDestination,
855
1033
  _stmtDeleteByPK: stmtDeleteByPK,
856
1034
  _stmtUpsert: stmtUpsert,
857
1035
  }
@@ -961,8 +1139,54 @@ onFinalize(async () => {
961
1139
  }
962
1140
  }
963
1141
 
1142
+ // Everything whose snapshot this pass removes: deleted entities, their
1143
+ // paginated children, and children dropped by a pagination shrink.
1144
+ const goingAway = new Set(deleted)
1145
+ for (const { id } of childrenToDelete) goingAway.add(id)
1146
+ for (const parentId of deleted) {
1147
+ for (const row of m._stmtSelectByParent.all(parentId)) goingAway.add(row.id)
1148
+ }
1149
+
964
1150
  // 2d. Unlink stale output files (async, parallel-friendly).
1151
+ //
1152
+ // Never unlink a destination another entity still claims. Two entities
1153
+ // can render to one path — an empty `index.md` beside the real
1154
+ // `index.yml` — and deleting one of them was taking the shared output
1155
+ // with it: the file vanished while the survivor's snapshot still said it
1156
+ // was there, the survivor's own source had not changed so nothing
1157
+ // re-rendered it, and --verify reported it missing.
1158
+ //
1159
+ // That made "resolve the collision by deleting the stub" delete the
1160
+ // homepage, which is the opposite of what the operator asked for and the
1161
+ // exact operation the new collision reporting invites.
965
1162
  for (const { destination, reason } of filesToUnlink) {
1163
+ // "Still claimed" means by something that SURVIVES this pass. The
1164
+ // ids going away here are not just the deleted entities: pagination
1165
+ // children staged above are removed too, and counting a child's own
1166
+ // snapshot as a claimant would keep every shrunk page on disk
1167
+ // forever.
1168
+ const stillClaimed = m._stmtSelectByDestination.all(destination)
1169
+ .filter(row => row.id !== undefined && !goingAway.has(row.id))
1170
+ if (stillClaimed.length) {
1171
+ // Keep the file — deleting a live page's output is worse than any
1172
+ // staleness — but do NOT let the state go quiet. The bytes on
1173
+ // disk were written by the entity that just went away, and the
1174
+ // survivor's snapshot recorded that same hash (each render hashes
1175
+ // the file after writing, so the loser recorded the winner's
1176
+ // bytes). Left alone, verify would compare the survivor's
1177
+ // snapshot against the deleted entity's output and report OK.
1178
+ //
1179
+ // Dropping the survivor's snapshot for this destination makes it
1180
+ // an orphan — a file no snapshot claims, which is exactly what it
1181
+ // is — so verify warns instead of blessing it, and the next time
1182
+ // the survivor renders it is `never-rendered` rather than skipped.
1183
+ m._stmtDeleteByDestination.run(destination)
1184
+ logger.warn(
1185
+ '%s: %s is also written by %s — keeping the file, but its bytes came from the '
1186
+ + 'deleted entity. Re-render or --force to refresh it.',
1187
+ reason, destination, stillClaimed.map(r => r.id).join(', '))
1188
+ continue
1189
+ }
966
1190
  const filePath = path.join(runtime.options.outputFolder, destination)
967
1191
  try {
968
1192
  await unlink(filePath)
@@ -982,18 +982,14 @@ export function api(options = {}) {
982
982
  if (!runtime.manifest?.verify) {
983
983
  return res.status(503).json({ error: 'No manifest available — nothing to verify against' })
984
984
  }
985
- const diff = await runtime.manifest.verify()
986
- const errors = diff.missing.length + diff.mismatched.length
987
- const warnings = diff.orphaned.length + diff.unverifiable.length
988
985
  // 200 either way — the check ran and this is its answer. A
989
986
  // CI gate reads `verdict`, which mirrors the CLI's exit
990
987
  // vocabulary, rather than inferring from a status code that
991
- // would conflate "drift found" with "request failed".
992
- return res.json({
993
- verdict: errors > 0 ? 'FAIL' : warnings > 0 ? 'WARN' : 'OK',
994
- snapshots: runtime.manifest.size?.() ?? null,
995
- ...diff,
996
- })
988
+ // would conflate "drift found" with "request failed". The
989
+ // verdict comes from the manifest so this route cannot
990
+ // disagree with the CLI about what counts as a failure.
991
+ const diff = await runtime.manifest.verify()
992
+ return res.json({ snapshots: runtime.manifest.size?.() ?? null, ...diff })
997
993
  } catch (err) {
998
994
  logger.error('Api verify error: %s', err.message)
999
995
  return res.status(500).json({ error: err.message })
package/src/refs.js CHANGED
@@ -85,6 +85,16 @@ export const REFS_SCHEMA = `
85
85
  `
86
86
  registerSchema('mikser_refs', REFS_SCHEMA)
87
87
 
88
+ // json_tree's `fullkey` → the field path the ref index uses.
89
+ // `$.meta.items[0].href` → `items[0].href`, and a quoted segment
90
+ // (`$.meta."$author"`, which is how json_tree spells a key starting with
91
+ // `$`) loses its quotes so both spellings compare equal.
92
+ function normalizeMetaPath(fullkey) {
93
+ return String(fullkey)
94
+ .replace(/^\$\.meta\.?/, '')
95
+ .replace(/"([^"]*)"/g, '$1')
96
+ }
97
+
88
98
  // Build the index handle over the provided sqlite database. Prepares
89
99
  // the SQL statements once at construction; subsequent reads/writes
90
100
  // reuse them.
@@ -100,6 +110,16 @@ export function createIndex(db) {
100
110
  SELECT source_id, field FROM mikser_refs
101
111
  WHERE target_ref = ? AND kind = 'ref'
102
112
  `)
113
+ // Any string value anywhere under an entity's meta that equals the
114
+ // target — including inside arrays, which is where nav and footer link
115
+ // lists live. json_tree walks the stored JSON, so no schema and no
116
+ // write-time indexing is involved.
117
+ const stmtInboundMetaValue = db.prepare(`
118
+ SELECT DISTINCT e.id AS id, j.fullkey AS field
119
+ FROM mikser_entities e, json_tree(e.data, '$.meta') j
120
+ WHERE j.type = 'text' AND j.value = ?
121
+ `)
122
+
103
123
  const stmtOutboundStatic = db.prepare(`
104
124
  SELECT field, target_ref FROM mikser_refs
105
125
  WHERE source_id = ? AND kind = 'ref'
@@ -196,9 +216,44 @@ export function createIndex(db) {
196
216
 
197
217
  // -- Read API ------------------------------------------------------
198
218
 
219
+ // Everything that points at `ref`, by BOTH mechanisms.
220
+ //
221
+ // The index holds `$`-keyed refs only, because those are the ones the
222
+ // engine resolves and invalidates on. But a site links to a page far
223
+ // more often with a plain string — `items: [{ label, href: '/about' }]`
224
+ // in a nav or footer document — and asking "what breaks if I delete
225
+ // this" got `count: 0` while two live pages linked to it. A silent miss
226
+ // is worse than no answer for that question.
227
+ //
228
+ // Plain values are found at READ time with json_tree over each entity's
229
+ // meta rather than indexed at write time: indexing every string in every
230
+ // meta would put the whole catalog in the edge table to serve a
231
+ // diagnostic, and the engine does not invalidate on plain strings
232
+ // anyway — which is itself worth knowing, and is why the two kinds stay
233
+ // labelled rather than merged.
199
234
  function inboundFor(ref) {
200
- return stmtInboundStatic.all(ref)
201
- .map(r => ({ id: r.source_id, field: r.field }))
235
+ const refs = stmtInboundStatic.all(ref)
236
+ .map(r => ({ id: r.source_id, field: r.field, kind: 'ref' }))
237
+ const seen = new Set(refs.map(r => `${r.id}|${r.field}`))
238
+ const hrefs = []
239
+ for (const row of stmtInboundMetaValue.all(ref)) {
240
+ const field = normalizeMetaPath(row.field)
241
+ // A `$`-keyed path is the ref index's territory by definition, and
242
+ // json_tree finds those values too — reporting them again as
243
+ // `href` would double-count every ref and mislabel it. Note that
244
+ // json_tree QUOTES a key beginning with `$` (`$.meta."$author"`),
245
+ // so this has to run after unquoting or the two spellings never
246
+ // meet and the dedup below misses.
247
+ if (field.split('.').some(seg => seg.startsWith('$'))) continue
248
+ // `meta.href` / `meta.url` at the top level are the entity's own
249
+ // ADDRESS, not a link to something else. Without this, asking
250
+ // what points at /about lists /documents/bg/about.md — the page
251
+ // itself — among the things that would break if it were deleted.
252
+ if (field === 'href' || field === 'url') continue
253
+ if (seen.has(`${row.id}|${field}`)) continue
254
+ hrefs.push({ id: row.id, field, kind: 'href' })
255
+ }
256
+ return [...refs, ...hrefs]
202
257
  }
203
258
 
204
259
  function outboundFor(sourceId) {
@@ -628,9 +683,17 @@ export function createRefs(db, prebuiltIndex = null) {
628
683
  }
629
684
  if (from === to) return { from, to, updated: [], failures: [] }
630
685
 
631
- const entries = index.inboundFor(from)
686
+ // `$`-keyed refs only, deliberately. inboundFor also reports
687
+ // plain string matches now — a nav item's `href` — but writeEntity
688
+ // patches `$`-keyed values, and rewriting arbitrary meta strings
689
+ // through it is a file-mutating change nobody asked for. A plain
690
+ // href pointing at the old name is REPORTED as unrewritten below
691
+ // rather than silently rewritten or silently skipped.
692
+ const all = index.inboundFor(from)
693
+ const entries = all.filter(e => e.kind !== 'href')
694
+ const unrewritten = all.filter(e => e.kind === 'href')
632
695
  if (entries.length === 0) {
633
- return { from, to, updated: [], failures: [] }
696
+ return { from, to, updated: [], failures: [], unrewritten }
634
697
  }
635
698
 
636
699
  const byEntity = new Map()
@@ -658,7 +721,7 @@ export function createRefs(db, prebuiltIndex = null) {
658
721
  }
659
722
  }
660
723
 
661
- return { from, to, updated, failures }
724
+ return { from, to, updated, failures, unrewritten }
662
725
  },
663
726
  }
664
727
  }
package/src/report.js CHANGED
@@ -37,6 +37,46 @@ function reportWanted() {
37
37
  return !!(runtime.options?.json || runtime.options?.reportRequested)
38
38
  }
39
39
 
40
+ // How many finished cycles to keep. Small on purpose: this is "what did my
41
+ // last few edits do", not an audit log, and each entry holds one record per
42
+ // entity rendered in that cycle.
43
+ const HISTORY_LIMIT = 10
44
+
45
+ function history() {
46
+ runtime.state ??= {}
47
+ runtime.state.reportHistory ??= []
48
+ return runtime.state.reportHistory
49
+ }
50
+
51
+ // The id the NEXT cycle will carry.
52
+ //
53
+ // A caller that writes a file needs to name the cycle its write will be
54
+ // picked up by BEFORE that cycle exists — otherwise "did my edit land" is
55
+ // unanswerable except by watching the clock. Writes go through the
56
+ // watcher's debounce, so the cycle after the current one is the answer.
57
+ export function nextCycleId() {
58
+ return (runtime.state?.cycle?.id ?? 0) + 1
59
+ }
60
+
61
+ export function currentCycle() {
62
+ return runtime.state?.cycle ?? null
63
+ }
64
+
65
+ // Finished cycles, newest first. `n` of them.
66
+ export function cycleHistory(n = 1) {
67
+ return history().slice(-Math.max(1, n)).reverse()
68
+ }
69
+
70
+ // Resolves when a cycle with at least this id has finished. Used by a
71
+ // caller that wants its write and the resulting report in one round trip
72
+ // instead of polling.
73
+ export function whenCycleCompletes(id) {
74
+ const done = history().find(c => c.id >= id)
75
+ if (done) return Promise.resolve(done)
76
+ runtime.state.cycleWaiters ??= []
77
+ return new Promise(resolve => runtime.state.cycleWaiters.push({ id, resolve }))
78
+ }
79
+
40
80
  // Cleared at the start of every cycle, so the report always describes the
41
81
  // LAST one rather than everything since the process started.
42
82
  //
@@ -49,14 +89,42 @@ function reportWanted() {
49
89
  // across cycles by design — it is the retry marker's in-memory twin, and
50
90
  // the exit code depends on this cycle's count, which resetRenderErrors
51
91
  // handles at the same point.
92
+ //
93
+ // Reset and IDENTITY are the same event — a report that describes "the last
94
+ // cycle" is only meaningful if something names which cycle that was, and a
95
+ // caller waiting on its own write needs to compare against something.
52
96
  export function resetReport() {
53
97
  if (!runtime.state) return
98
+ const previous = runtime.state.cycle
99
+ if (previous && !previous.finishedAt) finishCycle()
100
+ runtime.state.cycle = { id: nextCycleId(), startedAt: Date.now(), finishedAt: null }
54
101
  runtime.state.report = { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
55
102
  runtime.state.renderErrors = []
56
103
  }
57
104
 
105
+ // End of a cycle: stamp it, file it, and wake anyone waiting on it.
106
+ export function finishCycle() {
107
+ if (!runtime.state?.cycle || runtime.state.cycle.finishedAt) return
108
+ runtime.state.cycle.finishedAt = Date.now()
109
+ const record = { ...runtime.state.cycle, ...buildReport() }
110
+ const kept = history()
111
+ kept.push(record)
112
+ while (kept.length > HISTORY_LIMIT) kept.shift()
113
+
114
+ const waiters = runtime.state.cycleWaiters ?? []
115
+ runtime.state.cycleWaiters = waiters.filter(w => {
116
+ if (record.id < w.id) return true
117
+ w.resolve(record)
118
+ return false
119
+ })
120
+ }
121
+
58
122
  function store() {
123
+ // The first cycle never passes through resetReport — that fires on the
124
+ // watcher's trigger, and a cold build has no earlier cycle to reset from.
125
+ // Without this the build everyone looks at first reports cycleId: null.
59
126
  runtime.state ??= {}
127
+ runtime.state.cycle ??= { id: 1, startedAt: Date.now(), finishedAt: null }
60
128
  runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
61
129
  return runtime.state.report
62
130
  }
@@ -157,7 +225,13 @@ export function renderErrorCount() {
157
225
 
158
226
  export function buildReport() {
159
227
  const report = store()
228
+ const cycle = runtime.state?.cycle
160
229
  return {
230
+ // Which cycle this describes. Without it, two reports read the same
231
+ // and "is this my edit's cycle or the one before it" has no answer.
232
+ cycleId: cycle?.id ?? null,
233
+ startedAt: cycle?.startedAt ?? null,
234
+ finishedAt: cycle?.finishedAt ?? null,
161
235
  rendered: report.rendered,
162
236
  skipped: report.skipped,
163
237
  unchanged: report.unchanged,