mikser-io 10.6.0 → 10.8.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": "10.6.0",
3
+ "version": "10.8.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -1593,6 +1593,16 @@ The full version, with what each code means: docs/diagnostics.md`)
1593
1593
  // undefined here. The logger has no such problem — it writes during the
1594
1594
  // run, by which time options exist.
1595
1595
  const quietStdout = ['--json', '--tool', '--tools'].some(flag => process.argv.includes(flag))
1596
+ // The banner is an info record, so a level above info must not print it.
1597
+ //
1598
+ // `--log silent` printed the version line and nothing else — the one line
1599
+ // the flag most obviously promises to remove — because this runs before
1600
+ // the level is applied. Only the FORWARDED path was ever silent, and that
1601
+ // is the path the test exercises, so the test could not see it.
1602
+ //
1603
+ // argv again, for the reason quietStdout reads it: commander parses in a
1604
+ // lifecycle hook, which runs after setup() returns.
1605
+ if (!argvWantsInfo()) return runtime
1596
1606
  // Through the logger when nobody is watching, so it gets a timestamp.
1597
1607
  //
1598
1608
  // This line marks a process start, which in a supervisor's log is the
@@ -1625,6 +1635,26 @@ The full version, with what each code means: docs/diagnostics.md`)
1625
1635
  return runtime
1626
1636
  }
1627
1637
 
1638
+ // The level asked for on the command line, before commander has parsed.
1639
+ // Both spellings, both flags: `--log warn` and `--log=warn`, and
1640
+ // `--log-install`, which on a run with no instance to forward to sets the
1641
+ // level for this process.
1642
+ function argvLogLevel() {
1643
+ for (const flag of ['--log', '--log-install', '-l']) {
1644
+ const at = process.argv.indexOf(flag)
1645
+ if (at >= 0 && process.argv[at + 1]) return process.argv[at + 1]
1646
+ const inline = process.argv.find(arg => arg.startsWith(`${flag}=`))
1647
+ if (inline) return inline.slice(flag.length + 1)
1648
+ }
1649
+ return null
1650
+ }
1651
+
1652
+ function argvWantsInfo() {
1653
+ const level = argvLogLevel()
1654
+ if (!level || !LOG_LEVELS.includes(level)) return true
1655
+ return LOG_LEVELS.indexOf(level) <= LOG_LEVELS.indexOf('info')
1656
+ }
1657
+
1628
1658
  export function useLogger() {
1629
1659
  return runtime.engine?.logger
1630
1660
  }
package/src/instance.js CHANGED
@@ -218,10 +218,29 @@ let server = null
218
218
  // both into one undifferentiated stream throws that away, and the client can
219
219
  // only guess — it guessed stderr, so every forwarded document landed where no
220
220
  // consumer looks while the command exited 0.
221
- function captureOutput(onChunk) {
221
+ // `echoStdout: false` frames the chunk to the client and writes NOTHING
222
+ // locally.
223
+ //
224
+ // The instance wears the client's output so an operator watching it sees what
225
+ // a forwarded request did — right for log lines, wrong for a DOCUMENT. A
226
+ // --json request put its 364-line report into the instance's own console as
227
+ // well as onto the client's stdout, and under pm2 that is 364 lines in the out
228
+ // log for every report an agent asks for.
229
+ //
230
+ // The seam is exact rather than a guess: under --json / --tool the logger
231
+ // writes to stderr and stdout carries only the document (see logger.js), so
232
+ // stdout chunks during a document-carrying request ARE the document.
233
+ function captureOutput(onChunk, { echoStdout = true } = {}) {
222
234
  const originals = [process.stdout.write, process.stderr.write]
223
235
  const patch = (stream, original, name) => function (chunk, encoding, callback) {
224
236
  try { onChunk(typeof chunk === 'string' ? chunk : chunk.toString(), name) } catch { /* client gone */ }
237
+ if (!echoStdout && name === 'stdout') {
238
+ // write()'s callback is the second argument when encoding is
239
+ // omitted. Dropped, a caller awaiting the drain never resumes.
240
+ const done = typeof encoding === 'function' ? encoding : callback
241
+ if (typeof done === 'function') process.nextTick(done)
242
+ return true
243
+ }
225
244
  return original.call(stream, chunk, encoding, callback)
226
245
  }
227
246
  process.stdout.write = patch(process.stdout, originals[0], 'stdout')
@@ -242,6 +261,12 @@ function captureOutput(onChunk) {
242
261
  // not what tells the two apart. Different configs are different files.
243
262
  // Does this request name a level that does not exist? Pure, so the refusal
244
263
  // can be decided before anything is applied.
264
+ // Does this request's answer go on stdout as a document?
265
+ // The same trio logger.js gates the progress bar on, for the same reason.
266
+ function carriesDocument(request) {
267
+ return Boolean(request?.json || request?.tool || request?.tools)
268
+ }
269
+
245
270
  function logRequestRefusal(request) {
246
271
  for (const [flag, level] of [['--log', request?.log], ['--log-install', request?.logInstall]]) {
247
272
  if (level !== undefined && level !== null && !LOG_LEVELS.includes(level)) {
@@ -296,7 +321,9 @@ async function configStale() {
296
321
  // the only process that can answer correctly. Same guards as a build: wrong
297
322
  // config refuses, drifted config refuses.
298
323
  async function serveReport(socket, request, logger) {
299
- const restore = captureOutput((chunk, stream) => frame(socket, { type: 'log', chunk, stream }))
324
+ const restore = captureOutput(
325
+ (chunk, stream) => frame(socket, { type: 'log', chunk, stream }),
326
+ { echoStdout: !carriesDocument(request) })
300
327
  let code = 0
301
328
  try {
302
329
  code = await withRequestOutput(request, () => runReportOnly(request)) ?? 0
@@ -501,7 +528,9 @@ async function withRequestOutput(request, run) {
501
528
  }
502
529
 
503
530
  async function serveBuild(socket, request, logger) {
504
- const restore = captureOutput((chunk, stream) => frame(socket, { type: 'log', chunk, stream }))
531
+ const restore = captureOutput(
532
+ (chunk, stream) => frame(socket, { type: 'log', chunk, stream }),
533
+ { echoStdout: !carriesDocument(request) })
505
534
  let code = 0
506
535
  try {
507
536
  // Fire the pending debounce rather than waiting it out.
package/src/journal.js CHANGED
@@ -227,9 +227,8 @@ export async function* useJournal(name, operations, signal) {
227
227
  stopProgress()
228
228
  throw new AbortError()
229
229
  }
230
- updateProgress()
231
-
232
230
  const entry = rowToEntry(row)
231
+ updateProgress(entry.entity?.id)
233
232
  const originalEntity = row.entity // already a JSON string
234
233
 
235
234
  yield entry
package/src/logger.js CHANGED
@@ -28,6 +28,7 @@ import pino from 'pino'
28
28
  import pretty from 'pino-pretty'
29
29
  import Gauge from 'gauge'
30
30
  import { Writable } from 'node:stream'
31
+ import path from 'node:path'
31
32
  import runtime from './runtime.js'
32
33
  import { useLogger } from './engine.js'
33
34
  import { onLoad, onFinalized } from './lifecycle.js'
@@ -482,10 +483,65 @@ onLoad(() => {
482
483
  runtime.engine.logger = createMikserLogger(level)
483
484
  })
484
485
 
485
- // Progress API. Surface preserved: trackProgress / updateProgress /
486
- // stopProgress / updateProgressDetails. Gauge-backed; no-op in non-TTY
487
- // contexts or when runtime.options.info is false (i.e. --debug / --trace
488
- // modes, where logs are voluminous and a bar would just be noise).
486
+ // Progress API: trackProgress / updateProgress / stopProgress /
487
+ // updateProgressDetails. A gauge where a terminal is watching, coded records
488
+ // where one is not, and nothing at all for a phase that took no time.
489
+
490
+ // Long phases only, and long means TIME.
491
+ //
492
+ // A record per quartile is right for 800 documents and absurd for 5. Eleven
493
+ // of a plain build's thirteen phases carry exactly five items, so reporting
494
+ // them the way a bar counts took a piped no-op build from 32 lines to 97 —
495
+ // sixty-five progress records, five in the same millisecond to say a phase
496
+ // did nothing. That is written into the log a deployment keeps, which is the
497
+ // log the installed-level expiry exists to protect.
498
+ //
499
+ // A minimum TOTAL would fix the count and get the other half wrong: four PDFs
500
+ // through Chrome is a small phase and a slow one, and it is exactly the phase
501
+ // worth narrating. Elapsed time is what "long" means and it needs no
502
+ // per-phase tuning.
503
+ //
504
+ // It is an INTERVAL rather than a threshold, and quartiles are gone with it.
505
+ // A quartile is a fraction of the WORK, so it says nothing about how often a
506
+ // line appears: four records land in three seconds on a fast phase and four
507
+ // records cover an hour on a slow one. Time is the axis a reader cares about,
508
+ // so one line per interval, however much work passed in between.
509
+ //
510
+ // This gates the RUNNING commentary only. `progress-finished` is not behind
511
+ // it: every phase says that it ran and what it cost, whatever the duration.
512
+ // Gated, a short phase produced no record at all and a build could not say
513
+ // what it had done — and off a TTY that line is the only place phase timings
514
+ // come from.
515
+ export const PROGRESS_INTERVAL_MS = 30_000
516
+
517
+ // Which item, not just how far. A phase name alone says a build is doing
518
+ // something; the thing a stuck build needs to say is WHAT it is stuck on.
519
+ function progressDetail(detail) {
520
+ if (detail === null || detail === undefined || detail === '') return null
521
+ const text = String(detail)
522
+ const root = runtime.options?.workingFolder
523
+ if (!root || !path.isAbsolute(text)) return text
524
+ // An entity id LOOKS absolute and is not a filesystem path.
525
+ // `/documents/p3706.md` relativised against the working folder becomes
526
+ // `../../../documents/p3706.md`, which names nothing anyone can act on.
527
+ // So shorten only what is genuinely INSIDE the folder, and leave every
528
+ // other string exactly as the call site handed it over.
529
+ const relative = path.relative(root, text)
530
+ return relative.startsWith('..') ? text : relative
531
+ }
532
+
533
+ // `0s` for anything under a second reports the whole phase as nothing. Below
534
+ // a second the number IS the information.
535
+ function formatDuration(ms) {
536
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`
537
+ }
538
+
539
+ function emitProgress({ name, total, value, detail }) {
540
+ const at = progressDetail(detail)
541
+ const fields = { code: 'progress', phase: name, total, value }
542
+ if (at) useLogger()?.info({ ...fields, detail: at }, '%s: %d/%d — %s', name, value, total, at)
543
+ else useLogger()?.info(fields, '%s: %d/%d', name, value, total)
544
+ }
489
545
 
490
546
  export function trackProgress(name, total) {
491
547
  if (!name || !total) return
@@ -512,26 +568,31 @@ export function trackProgress(name, total) {
512
568
  // --json would have extended that to every machine reading the output.
513
569
  // Losing the graphics is the point; losing the information is not.
514
570
  const drawn = !carriesDocument && Boolean(process.stdout.isTTY) && Boolean(runtime.options.info)
515
- currentBar = { name, total, value: 0, started: Date.now(), drawn, milestone: 0 }
571
+ currentBar = { name, total, value: 0, started: Date.now(), drawn, lastReport: Date.now(), detail: null }
516
572
  if (drawn) ensureGauge().show({ section: name, subsection: `0/${total}` }, 0)
517
- else logger.info({ code: 'progress', phase: name, total, value: 0 }, '%s: 0/%d', name, total)
518
573
  }
519
574
 
520
- export function updateProgress() {
575
+ export function updateProgress(detail) {
521
576
  if (!currentBar) return
522
577
  currentBar.value++
578
+ // The call site hands over whatever identifies the item it is on — a
579
+ // path, an id — and pays one assignment for it. Formatting happens at
580
+ // emit time, at most four times a phase, so naming the work costs the
581
+ // 14k-entity import nothing.
582
+ if (detail !== undefined) currentBar.detail = detail
523
583
  const { name, total, value, drawn } = currentBar
524
584
  if (drawn) {
525
585
  gauge?.show({ section: name, subsection: `${value}/${total}` }, value / total)
526
586
  } else {
527
- // Quartiles, not every item: a bar redraws in place and costs one
528
- // line, a log record does not. 800 documents is 800 lines of noise if
529
- // this counts the way the bar does.
530
- const reached = Math.floor((value / total) * 4)
531
- if (reached > currentBar.milestone && value < total) {
532
- currentBar.milestone = reached
533
- useLogger()?.info({ code: 'progress', phase: name, total, value },
534
- '%s: %d/%d', name, value, total)
587
+ // One line per interval, not per item and not per quartile: a bar
588
+ // redraws in place and costs one line, a log record does not, so 800
589
+ // documents is 800 lines of noise if this counts the way the bar does.
590
+ // A phase that finishes inside the interval says nothing at all while
591
+ // it runs its finished line covers it.
592
+ const now = Date.now()
593
+ if (now - currentBar.lastReport >= PROGRESS_INTERVAL_MS && value < total) {
594
+ currentBar.lastReport = now
595
+ emitProgress(currentBar)
535
596
  }
536
597
  }
537
598
  if (value >= total) stopProgress()
@@ -541,16 +602,31 @@ export function stopProgress() {
541
602
  if (!currentBar) return
542
603
  const logger = useLogger()
543
604
  const { name, total, value, started } = currentBar
605
+ const ms = Date.now() - started
544
606
  gauge?.hide()
545
607
  // Structured either way, so a machine reading --json's stderr gets the
546
608
  // same facts a person reads off the bar.
609
+ //
610
+ // This line has to stand alone. With the running commentary on an
611
+ // interval it is the ONLY record most phases produce, and `Documents
612
+ // import finished: 5 0s` said neither what the five were nor how long it
613
+ // took — a count with no subject and a duration rounded away to nothing.
614
+ //
615
+ // The subject is the PHASE, not an item. The last entity a phase happened
616
+ // to walk is not what the phase was about: seven journal phases in a row
617
+ // reported the same `/layouts/page.hbs` because that is where the walk
618
+ // ended, and `Files import finished: 3, last .../social-fb.svg` put a
619
+ // filename into the build log that nothing had anything to say about — it
620
+ // broke a test asserting that file is never mentioned, which is exactly
621
+ // the misreading it invites. `Files import finished: 3 in 3ms` already
622
+ // says what finished, how much of it, and what it cost. The running
623
+ // records keep the item, because there it shows MOVEMENT.
547
624
  if (value < total) {
548
625
  logger.warn({ code: 'progress-unfinished', phase: name, total, value, missing: total - value },
549
- '%s unfinished: %d', name, total - value)
626
+ '%s unfinished: %d of %d after %s', name, total - value, total, formatDuration(ms))
550
627
  } else {
551
- const ms = Date.now() - started
552
628
  logger.info({ code: 'progress-finished', phase: name, total, ms },
553
- '%s finished: %d %ds', name, total, Math.round(ms / 1000))
629
+ '%s finished: %d in %s', name, total, formatDuration(ms))
554
630
  }
555
631
  currentBar = null
556
632
  }
@@ -228,7 +228,7 @@ export function files(options = {}) {
228
228
  // nothing to emit. Progress ticks either way — a gated file
229
229
  // was still looked at.
230
230
  const newChecksum = await gateChecksum(source, id, { priorChecksums })
231
- updateProgress()
231
+ updateProgress(id)
232
232
  if (newChecksum === null) return
233
233
  await createEntity({
234
234
  id,
@@ -100,7 +100,7 @@ export function observer(options = {}) {
100
100
  synced++
101
101
  }
102
102
  }
103
- updateProgress()
103
+ updateProgress(meta.id)
104
104
  }
105
105
 
106
106
  const entitiesToRemove = await findEntities({
@@ -114,7 +114,7 @@ export function observer(options = {}) {
114
114
  for (let entity of entitiesToRemove) {
115
115
  deleteEntity(entity)
116
116
  removed++
117
- updateProgress()
117
+ updateProgress(entity.id)
118
118
  }
119
119
  if (synced || removed) {
120
120
  logger.debug('Syncing api [%s] synced: %d, removed: %d', collection, synced, removed)
@@ -145,7 +145,7 @@ export function resources(options = {}) {
145
145
  logger.error('Resource error: %s %s %s', entity.id, resource, err.message)
146
146
  }
147
147
  }
148
- updateProgress()
148
+ updateProgress(resource)
149
149
  }
150
150
 
151
151
  const resourceFiles = await globby('**/*', { cwd: runtime.options.resourcesFolder })
@@ -226,7 +226,7 @@ export function resources(options = {}) {
226
226
  checksum: await checksum(resource)
227
227
  })
228
228
  }
229
- updateProgress()
229
+ updateProgress(resource)
230
230
  }, { concurrency: 10, signal })
231
231
  count && logger.info('Downloaded: %d', count)
232
232
  }
package/src/source.js CHANGED
@@ -431,7 +431,7 @@ export function useSource(core, options) {
431
431
  // is never the bottleneck.
432
432
  await pMap(files, async (file) => {
433
433
  await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums })
434
- if (phase === 'import') updateProgress()
434
+ if (phase === 'import') updateProgress(file)
435
435
  }, { concurrency: SCAN_CONCURRENCY })
436
436
 
437
437
  scanStats.deleted = await sweepDeleted(collection, scanned, async (e) => {