mikser-io 9.84.0 → 9.89.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.
@@ -767,7 +767,7 @@ answer is about your request. It is also faster, because a forwarded command
767
767
  never imports the config or the plugin graph, which is most of what a one-shot
768
768
  spends its time on.
769
769
 
770
- Three things it refuses or reports rather than guessing:
770
+ Four things it refuses or reports rather than guessing:
771
771
 
772
772
  - **A different config.** If you resolve `mikser.config.prod.js` and the
773
773
  instance is running `mikser.config.js`, it refuses. Building with the wrong
@@ -779,6 +779,14 @@ Three things it refuses or reports rather than guessing:
779
779
  to *become* the instance, which is not something a running one can do for
780
780
  you — it would have to open a port in your process. They exit 1 and say so,
781
781
  rather than building and leaving nothing on the port.
782
+ - **`--clear`.** Clearing removes the output folder and closes, unlinks and
783
+ reopens the cache database — a boot operation, which is why the wipe lives
784
+ where the database is opened and nowhere else. A running instance holds that
785
+ handle, has its manifest and plugin state loaded from what would be deleted,
786
+ and may be serving out of the folder being removed. It exits 1 and tells you
787
+ to stop the instance. It used to be accepted and ignored, so the build
788
+ reported success with nothing cleared; a half-clear would have been worse
789
+ than either, since afterwards nobody could say what state the folder was in.
782
790
  There is no opt-out. A flag for running a second engine on a held folder
783
791
  only ever enabled the accident this surface prevents, and stopping the
784
792
  instance serves every case it was reached for.
@@ -940,6 +948,15 @@ surfaces that turn silence into a statement:
940
948
  sitemap — and warns under `asset-missing`. Where both can see the
941
949
  same file, the output scan reports it and this one stays quiet.
942
950
 
951
+ A `url()` inside a **custom property** is resolved from the stylesheet that
952
+ substitutes it, for both of these questions and for the climb check below.
953
+ It is substituted where it is *used*, so the page that declared it in a
954
+ style attribute is not its base — a bundle at `styles/` is. Every emitted
955
+ stylesheet is a candidate, since which one substitutes the variable is not
956
+ knowable from the bytes; a clean resolution settles it, one that resolves
957
+ only by climbing is a genuine over-deep against *that* base, and a url that
958
+ resolves from none of them is reported as broken.
959
+
943
960
  A url under the assets folder gets a cause rather than a guess. Whether a
944
961
  preset covers a file is decided by `match` against the entity id, which is
945
962
  not visible from a url, so the assets plugin is asked and the answer is one
@@ -963,7 +980,11 @@ surfaces that turn silence into a statement:
963
980
  - **A link that works only by accident** — a url with one `..` too many
964
981
  still loads, because a browser discards a climb above the origin root
965
982
  rather than failing. Reported under `reference-over-deep`, separately
966
- from the outright failures, and grouped by how far each climbed:
983
+ from the outright failures, and grouped by how far each climbed. A custom
984
+ property is judged against its stylesheet here too: judged against the page
985
+ it produced a climb report for references that are correct, with a reason
986
+ — "a browser discards the extra `..`" — that was false for them, which
987
+ sends the reader to fix a base that is right:
967
988
  - **One url, or several climbing different distances** — each is a
968
989
  latent 404, working today and broken as soon as the same markup
969
990
  renders one level deeper.
package/docs/plugins.md CHANGED
@@ -198,15 +198,40 @@ files({
198
198
  ```
199
199
 
200
200
  **Entity properties set:**
201
- - `id`: `/{folder}/{relativePath}`
201
+ - `id`: `/files/{relativePath}` — never carries the `outputFolder` prefix
202
202
  - `collection`: `'files'`
203
203
  - `type`: `'file'`
204
204
  - `format`: File extension
205
- - `destination`: Target path in output folder
205
+ - `uri`: the **source** file, the same meaning it has in every other
206
+ collection. It named the published symlink until 9.86.0, which made `uri`
207
+ mean the source for a document and the destination for a file — see below
208
+ - `source`: the same path; kept because presets read it
209
+ - `name` / `meta.url`: where the file is **published**, carrying the
210
+ `outputFolder` prefix when one is configured
206
211
  - `checksum`: MD5 checksum
212
+ - `link`: the resolved target, when the source is itself a symlink
207
213
 
208
214
  **Watch support:** Yes.
209
215
 
216
+ **Deleted files are reconciled by the scan**, not only by the watcher. A file
217
+ removed while nothing was watching used to leave a dangling symlink in the
218
+ deployed output, its catalog row, and anything derived from it — on every
219
+ later build. Neither a plain rebuild nor `--force` removed them; only
220
+ `--clear` did.
221
+
222
+ The scan now compares what it found against what the catalog holds and removes
223
+ the difference, scoped by `uri` to the files folder so entities another plugin
224
+ emitted into this collection (a CSV, a drive, an API) are left alone. It says
225
+ `Files removed: N no longer on disk` when it acts and nothing when it does not.
226
+
227
+ `--force` reconciles deletions too, as of 9.88.0. It used to skip this and the
228
+ equivalent sweep for documents and layouts, which made it the one flag that
229
+ could not fix what it is reached for — nothing about `--force` wipes the
230
+ catalog (that is `--clear`), so a row for a file that no longer exists simply
231
+ survived. Only `--clear` cleaned up.
232
+
233
+ The matching cleanup for derivatives lives in `assets` — see below.
234
+
210
235
  ---
211
236
 
212
237
  ### `layouts` *(sibling: `mikser-io-layouts`)*
@@ -419,6 +444,26 @@ The assets plugin processes whatever's on disk. Source files don't have to start
419
444
 
420
445
  Yes — the plugin watches both the source files and the preset modules. Editing a preset re-processes every input that matches it; editing a source re-processes just that input.
421
446
 
447
+
448
+ **Derivatives with no source are removed.** A derivative outlived its source:
449
+ deleting a file removed its catalog row and its published copy and left the
450
+ derived file, because the delete handler dropped the in-memory mapping and
451
+ nothing on disk. Narrowing a preset's `match` left one the same way. A stale
452
+ derivative passes every check — the url resolves and the bytes are there —
453
+ so the only cleanup was `--clear`, or deleting it by hand.
454
+
455
+ The post-cycle pass that already walks the revision markers now also asks, per
456
+ derivative, whether it still has a source that this preset still covers. It is
457
+ answered from the **catalog**, not from the delete event: a delete entry is
458
+ sparse (`{ id, type, collection }`) in every source plugin, so it cannot say
459
+ where the derivative went, while the catalog answers whatever route the orphan
460
+ arrived by — including ones that predate the fix. Reported as
461
+ `Assets removed: N derivative(s) with no source`.
462
+
463
+ It does nothing when the catalog is empty. Every check concludes "no source,
464
+ therefore orphan", and an empty catalog answers that for every derivative on
465
+ the site, so a failed import would otherwise delete the whole assets folder.
466
+
422
467
  ---
423
468
 
424
469
  ### `resources`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.84.0",
3
+ "version": "9.89.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/instance.js CHANGED
@@ -330,6 +330,35 @@ function refuseUnknownFlags(socket, request) {
330
330
  return true
331
331
  }
332
332
 
333
+ // `--clear` against a running instance.
334
+ //
335
+ // Clearing is a BOOT operation. It removes the output folder, and it closes,
336
+ // unlinks and reopens the cache database — which is why the wipe lives at the
337
+ // point the database is opened and nowhere else. An instance has that handle
338
+ // open with prepared statements held against it all over the engine, its
339
+ // manifest and plugin state loaded from what is about to be deleted, and a
340
+ // server answering requests out of the folder being removed. There is no
341
+ // moment mid-run where this can be done and still mean what the flag means.
342
+ //
343
+ // It travelled on the request and was never read, so `mikser --clear` against
344
+ // a watcher rebuilt normally and exited 0 having cleared nothing: the flag
345
+ // looked honoured. Refusing says the only thing that helps, which is that the
346
+ // instance has to stop first.
347
+ //
348
+ // A half-clear would be worse than either: it would report success for an
349
+ // operation the caller can no longer describe.
350
+ function refuseClear(socket, request) {
351
+ if (!request.clear) return false
352
+ frame(socket, {
353
+ type: 'refused',
354
+ reason: 'a mikser instance is running in this folder, and --clear cannot run while it does.',
355
+ detail: 'Clearing removes the output folder and reopens the cache database, both of which happen '
356
+ + 'once at startup — the running instance holds that database open and serves out of that '
357
+ + 'folder. Stop it and run this again.',
358
+ })
359
+ return true
360
+ }
361
+
333
362
  function refuseStale(socket, movedFile) {
334
363
  frame(socket, {
335
364
  type: 'refused',
@@ -451,6 +480,7 @@ export function serveInstance() {
451
480
  // original incident, and it is wrong whether or not it
452
481
  // writes anything.
453
482
  if (refuseUnknownFlags(socket, request)) return
483
+ if (refuseClear(socket, request)) return
454
484
  const wrongConfig = configMismatch(request.config)
455
485
  if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
456
486
  const movedFile = await configStale()
@@ -141,8 +141,32 @@ export function assets(options = {}) {
141
141
  // entity, and it is the hottest call in a render. Nothing is looked up
142
142
  // when the url is built. This runs at most a handful of times, after
143
143
  // everything has settled, and only when something is already wrong.
144
+ // A derivative wears the PRESET's extension, so a source is found by stem:
145
+ // `media/hero.webp` came from `media/hero.jpg`.
146
+ const stemOf = (value) => value.slice(0, value.length - path.extname(value).length)
147
+
148
+ // stem(entity.name) -> entities, built once per cycle.
149
+ //
150
+ // Two callers ask the same question from opposite directions — "why is
151
+ // this derivative missing" and "does this derivative still have a source"
152
+ // — so they share one index rather than each walking the catalog.
144
153
  let sourceIndex = null
145
154
  let sourceIndexCycle
155
+ async function sourceByStem() {
156
+ const cycle = runtime.state?.cycle?.id ?? null
157
+ if (sourceIndexCycle !== cycle) { sourceIndex = null; sourceIndexCycle = cycle }
158
+ if (!sourceIndex) {
159
+ sourceIndex = new Map()
160
+ for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
161
+ if (typeof candidate.name !== 'string') continue
162
+ const key = stemOf(candidate.name)
163
+ if (!sourceIndex.has(key)) sourceIndex.set(key, [])
164
+ sourceIndex.get(key).push(candidate)
165
+ }
166
+ }
167
+ return sourceIndex
168
+ }
169
+
146
170
  async function explainMissing(destination) {
147
171
  const assetsName = runtime.options.assets
148
172
  const parts = String(destination).replace(/^\/+/, '').split('/')
@@ -155,27 +179,12 @@ export function assets(options = {}) {
155
179
  return `no preset named '${preset}' is configured (configured: ${configured.join(', ') || 'none'})`
156
180
  }
157
181
 
158
- // The derivative wears the PRESET's extension, so the source is found
159
- // by stem `media/hero.webp` came from `media/hero.jpg`.
160
- const stem = (value) => value.slice(0, value.length - path.extname(value).length)
161
-
162
- // Built once per cycle and only on this path: a build with nothing
163
- // broken never walks the catalog for it.
164
- const cycle = runtime.state?.cycle?.id ?? null
165
- if (sourceIndexCycle !== cycle) { sourceIndex = null; sourceIndexCycle = cycle }
166
- if (!sourceIndex) {
167
- sourceIndex = new Map()
168
- for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
169
- if (typeof candidate.name !== 'string') continue
170
- const key = stem(candidate.name)
171
- if (!sourceIndex.has(key)) sourceIndex.set(key, [])
172
- sourceIndex.get(key).push(candidate)
173
- }
174
- }
175
-
176
- const source = (sourceIndex.get(stem(name)) ?? [])[0]
182
+ // Built only on this path: a build with nothing broken never walks
183
+ // the catalog for it.
184
+ const index = await sourceByStem()
185
+ const source = (index.get(stemOf(name)) ?? [])[0]
177
186
  if (!source) {
178
- return `no source file is named '${stem(name)}' — nothing would produce this derivative `
187
+ return `no source file is named '${stemOf(name)}' — nothing would produce this derivative `
179
188
  + 'under any preset'
180
189
  }
181
190
 
@@ -726,25 +735,79 @@ export function assets(options = {}) {
726
735
  } catch { /* a count is not worth failing a build over */ }
727
736
 
728
737
  let revisions = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
738
+
739
+ // Is the catalog answerable at all?
740
+ //
741
+ // Every check below concludes "no source, therefore orphan", and an
742
+ // EMPTY catalog answers that for every derivative on the site. A
743
+ // failed import or a scan that has not run yet would then delete the
744
+ // whole assets folder — a rebuild of every derivative at best, and at
745
+ // worst it happens on the machine that serves them. So the sweep only
746
+ // runs when there is something to be absent from.
747
+ const catalogSize = countEntities({ collection: { $ne: collection } })
748
+ const orphaned = []
749
+
729
750
  for (let revision of revisions) {
730
751
  const [preset] = revision.split(path.sep)
731
752
  const [assetsRevision] = revision.split('.').slice(-2, -1)
732
753
 
733
754
  if (!presets[preset]) {
734
755
  const assetsPresetFolder = path.join(runtime.options.assetsFolder, preset)
735
- const assetsPresetRemoved = false
756
+ // `let`. This was `const` with an assignment inside the try,
757
+ // so removing a preset folder threw TypeError into the empty
758
+ // catch on every pass and the log line was unreachable — the
759
+ // rm had already happened, so nothing looked wrong.
760
+ let assetsPresetRemoved = false
736
761
  try {
737
762
  await rm(assetsPresetFolder, { recursive: true, force: true })
738
763
  assetsPresetRemoved = true
739
- } catch { }
764
+ } catch { /* already gone, or not ours to remove */ }
740
765
  if (assetsPresetRemoved) {
741
766
  logger.debug('Assets preset removed: %s', assetsPresetFolder)
742
767
  }
743
- } else {
744
- if (Number.parseInt(assetsRevision) < presets[preset].checksum) {
745
- await unlink(path.join(runtime.options.assetsFolder, revision))
746
- }
768
+ continue
769
+ }
770
+
771
+ if (Number.parseInt(assetsRevision) < presets[preset].checksum) {
772
+ await unlink(path.join(runtime.options.assetsFolder, revision))
747
773
  }
774
+
775
+ // A derivative whose source is gone, or which this preset no
776
+ // longer covers.
777
+ //
778
+ // Deleting the source removed its catalog row and its published
779
+ // file, and left the derivative — the delete handler dropped the
780
+ // in-memory mapping and nothing on disk, so the only cleanup was
781
+ // --clear or doing it by hand. Narrowing a preset's `match` left
782
+ // one the same way, with nothing said.
783
+ //
784
+ // Answered from the catalog rather than from the delete event: a
785
+ // delete entry is sparse ({ id, type, collection }) by convention
786
+ // in every source plugin, so it cannot say where the derivative
787
+ // went. What still HAS a source is a question the catalog answers
788
+ // whatever route the orphan arrived by.
789
+ if (!catalogSize) continue
790
+ const derivative = revision.replace(/\.[^.]+\.md5$/, '')
791
+ const name = derivative.split(path.sep).slice(1).join('/')
792
+ const index = await sourceByStem()
793
+ const source = (index.get(stemOf(name)) ?? [])[0]
794
+ if (source && presetsSelecting(source).includes(preset)) continue
795
+ orphaned.push({
796
+ derivative,
797
+ marker: revision,
798
+ reason: source ? `preset '${preset}' no longer covers ${source.id}` : 'its source is gone',
799
+ })
800
+ }
801
+
802
+ for (const { derivative, marker } of orphaned) {
803
+ await unlink(path.join(runtime.options.assetsFolder, derivative)).catch(() => { /* already gone */ })
804
+ await unlink(path.join(runtime.options.assetsFolder, marker)).catch(() => { /* already gone */ })
805
+ }
806
+ if (orphaned.length) {
807
+ logger.info('Assets removed: %d derivative(s) with no source — %s',
808
+ orphaned.length,
809
+ orphaned.slice(0, 3).map(o => `${o.derivative} (${o.reason})`).join(', ')
810
+ + (orphaned.length > 3 ? ` and ${orphaned.length - 3} more` : ''))
748
811
  }
749
812
  })
750
813
 
@@ -3,6 +3,7 @@ import { mkdir, symlink, unlink, lstat, realpath } from 'fs/promises'
3
3
  import { globby } from 'globby'
4
4
  import pMap from 'p-map'
5
5
  import { checksumsByCollection } from '../catalog.js'
6
+ import { sweepDeleted } from '../source.js'
6
7
 
7
8
  export function files(options = {}) {
8
9
  return ({
@@ -38,10 +39,18 @@ export function files(options = {}) {
38
39
  return { uri, source }
39
40
  }
40
41
 
42
+ // The goal state is "the link is not there", so a link that is
43
+ // already gone is success, not an error. It genuinely happens: someone
44
+ // deletes a stale one by hand, and before this the next reconciliation
45
+ // threw on it.
41
46
  async function removeLink(relativePath) {
42
47
  let uri = path.join(runtime.options.outputFolder, relativePath)
43
48
  if (options.outputFolder) uri = path.join(runtime.options.outputFolder, options.outputFolder, relativePath)
44
- await unlink(path.resolve(uri))
49
+ try {
50
+ await unlink(path.resolve(uri))
51
+ } catch (err) {
52
+ if (err.code !== 'ENOENT') throw err
53
+ }
45
54
  }
46
55
 
47
56
  async function link(source) {
@@ -58,12 +67,24 @@ export function files(options = {}) {
58
67
  const source = path.join(runtime.options.filesFolder, relativePath)
59
68
  const format = path.extname(relativePath).substring(1).toLowerCase()
60
69
  const id = path.join(`/${collection}`, relativePath)
61
- let uri = path.join(runtime.options.outputFolder, relativePath)
62
70
  let name = relativePath
63
71
  if (options.outputFolder) {
64
- uri = path.join(runtime.options.outputFolder, options.outputFolder, relativePath)
65
72
  name = path.join(options.outputFolder, relativePath)
66
73
  }
74
+ // The SOURCE file, as in every other collection.
75
+ //
76
+ // It used to be the symlink in the output, which made `uri` mean
77
+ // one thing for documents and layouts and the opposite here — and
78
+ // three separate pieces of code carry scars from it: the render
79
+ // file helper keys its edges on `id` with a comment explaining
80
+ // that a uri edge "matches nothing" for files, locateEntityFile
81
+ // rejected every file entity as living outside its own collection
82
+ // folder, and sweepDeleted could not be called at all because it
83
+ // scopes by uri rooted at the folder a source owns.
84
+ //
85
+ // Where the bytes are PUBLISHED is meta.url, which every consumer
86
+ // already reads (ADR-0011).
87
+ const uri = source
67
88
 
68
89
  let synced = true
69
90
  switch (action) {
@@ -145,6 +166,7 @@ export function files(options = {}) {
145
166
  })
146
167
 
147
168
  onImport(async () => {
169
+ const logger = useLogger()
148
170
  await mkdir(runtime.options.outputFolder, { recursive: true })
149
171
  if (options.outputFolder) await mkdir(path.join(runtime.options.outputFolder, options.outputFolder), { recursive: true })
150
172
 
@@ -167,13 +189,19 @@ export function files(options = {}) {
167
189
  // repair path short of deleting them.
168
190
  const forced = runtime.options.force || runtime.catalog?.cacheInvalidated
169
191
  const priorChecksums = checksumsByCollection(collection)
192
+ const scanned = new Set()
170
193
  await pMap(paths, async relativePath => {
171
- const { uri, source } = await ensureLink(relativePath)
194
+ const { source } = await ensureLink(relativePath)
195
+ const uri = source
172
196
  let name = relativePath
173
197
  if (options.outputFolder) {
174
198
  name = path.join(options.outputFolder, relativePath)
175
199
  }
176
200
  const id = path.join(`/${collection}`, relativePath)
201
+ // Recorded before the checksum gate. The gate decides whether
202
+ // to re-emit; the sweep asks what still EXISTS, and a file
203
+ // skipped for being unchanged very much exists.
204
+ scanned.add(id)
177
205
  const newChecksum = await checksum(source)
178
206
  updateProgress()
179
207
  // Gate: if the catalog already has this entity with the same
@@ -196,6 +224,31 @@ export function files(options = {}) {
196
224
  link: await link(source),
197
225
  })
198
226
  }, { concurrency: 16 })
227
+
228
+ // What the catalog holds that the disk no longer does.
229
+ //
230
+ // The scan enumerates what exists and never asked the opposite
231
+ // question, so deleting a file with nothing watching left three
232
+ // things behind on every subsequent build: a DANGLING SYMLINK in
233
+ // the deployed output, the catalog row, and anything derived from
234
+ // it. Neither a plain rebuild nor --force removed them; only
235
+ // --clear did, or a watcher that happened to be running at the
236
+ // moment the file disappeared.
237
+ //
238
+ // ownerPrefix is the files folder, which is only a meaningful
239
+ // scope now that `uri` names the source. It keeps the sweep off
240
+ // entities another plugin emitted into this collection — a CSV, a
241
+ // drive, an API — which is the accident the parameter exists to
242
+ // prevent.
243
+ //
244
+ // Skipped under --force, like every other sweep: that guard lives
245
+ // in sweepDeleted and is shared with documents and layouts.
246
+ const deleted = await sweepDeleted(collection, scanned, async (entity) => {
247
+ await removeLink(path.relative(runtime.options.filesFolder, entity.uri))
248
+ await deleteEntity(entity)
249
+ logger.debug('files removed (file gone): %s', entity.name)
250
+ }, runtime.options.filesFolder)
251
+ if (deleted) logger.info('Files removed: %d no longer on disk', deleted)
199
252
  })
200
253
 
201
254
  return {
package/src/references.js CHANGED
@@ -198,21 +198,46 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
198
198
  const pageDir = path.dirname(file).slice(root.length).replace(/^\/+/, '')
199
199
 
200
200
  for (const { url, customProperty } of extractReferences(source)) {
201
- const { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
201
+ let { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
202
202
  checked++
203
203
 
204
- if (!exists.has(target)) {
205
- exists.set(target, existsSync(path.join(outputFolder, target)))
204
+ const resolves = (candidate) => {
205
+ if (!exists.has(candidate)) {
206
+ exists.set(candidate, existsSync(path.join(outputFolder, candidate)))
207
+ }
208
+ return exists.get(candidate)
206
209
  }
210
+ resolves(target)
207
211
 
208
- // Resolved from a stylesheet instead, and correct there.
209
- if (!exists.get(target) && customProperty && styleBases.some((base) => {
210
- const from = resolveUrl(base.dir, url, { root: base.root }).target
211
- if (!exists.has(from)) {
212
- exists.set(from, existsSync(path.join(outputFolder, from)))
212
+ // A custom property is resolved from the STYLESHEET, for BOTH
213
+ // questions this check asks.
214
+ //
215
+ // The first version of this only replaced the verdict when the
216
+ // page-relative target was missing, which left the climb check
217
+ // answering from a base it had already been told was the wrong
218
+ // one. Where the discarded `..` happened to land on a real file,
219
+ // the reference was reported as climbing above the site root, and
220
+ // the explanation said it loads "because a browser discards the
221
+ // extra `..`" — when it loads because it is correct where it is
222
+ // actually read from. A wrong reason on a correct reference is
223
+ // worse than the original false positive: it sends the reader to
224
+ // fix a base that is right.
225
+ //
226
+ // Clean beats floored beats missing. A stylesheet that resolves it
227
+ // without climbing settles it; one that resolves it only by
228
+ // climbing is a genuine over-deep against THAT base; and if none
229
+ // resolves it the page-relative answer stands and the url is
230
+ // simply broken.
231
+ if (customProperty && styleBases.length) {
232
+ let best = null
233
+ for (const base of styleBases) {
234
+ const from = resolveUrl(base.dir, url, { root: base.root })
235
+ if (!resolves(from.target)) continue
236
+ if (!from.overDeep) { best = from; break }
237
+ best ??= from
213
238
  }
214
- return exists.get(from)
215
- })) continue
239
+ if (best) ({ target, overDeep, floored } = best)
240
+ }
216
241
 
217
242
  // Broken outranks over-deep: a url that resolves nowhere is the
218
243
  // failure, and adding that it is also one level too deep is noise.
package/src/source.js CHANGED
@@ -130,7 +130,20 @@ export async function gateChecksum(file, id, { reload = false, priorChecksums, b
130
130
  // LRU population the scan already does, and we get rid of the 7GB JS
131
131
  // heap peak in return.
132
132
  export async function sweepDeleted(collection, scanned, onDelete, ownerPrefix) {
133
- if (runtime.options.force) return 0
133
+ // --force does NOT skip this any more.
134
+ //
135
+ // It used to, on a stated premise — "operator wants a full rebuild;
136
+ // deletes still flow naturally on the rebuild" — that is not true and can
137
+ // be disproved in one command: delete a document, run `mikser --force`,
138
+ // and both the entity and its output are still there, while an ordinary
139
+ // build removes them. Nothing about --force wipes the catalog (that is
140
+ // --clear, via forceWipe), so a row for a file that no longer exists
141
+ // simply survives.
142
+ //
143
+ // Which made --force the one flag that could not fix what it is reached
144
+ // for. It defeats the checksum GATE; the scanned set is built from the
145
+ // glob before any gate runs, so it is exactly as complete under --force as
146
+ // without it, and the sweep is no less accurate for running.
134
147
  if (!ownerPrefix) {
135
148
  throw new Error(
136
149
  'sweepDeleted: ownerPrefix is required — pass the absolute folder ' +