mikser-io 10.6.0 → 10.7.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.7.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,48 @@ 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, it needs no per-phase
502
+ // tuning, and it applies to the drawn path too — `finished: 5 0s` is the same
503
+ // non-information in a terminal, and its own `0s` says so.
504
+ export const PROGRESS_MIN_MS = 1000
505
+
506
+ // Which item, not just how far. A phase name alone says a build is doing
507
+ // something; the thing a stuck build needs to say is WHAT it is stuck on.
508
+ function progressDetail(detail) {
509
+ if (detail === null || detail === undefined || detail === '') return null
510
+ const text = String(detail)
511
+ const root = runtime.options?.workingFolder
512
+ if (!root || !path.isAbsolute(text)) return text
513
+ // An entity id LOOKS absolute and is not a filesystem path.
514
+ // `/documents/p3706.md` relativised against the working folder becomes
515
+ // `../../../documents/p3706.md`, which names nothing anyone can act on.
516
+ // So shorten only what is genuinely INSIDE the folder, and leave every
517
+ // other string exactly as the call site handed it over.
518
+ const relative = path.relative(root, text)
519
+ return relative.startsWith('..') ? text : relative
520
+ }
521
+
522
+ function emitProgress({ name, total, value, detail }) {
523
+ const at = progressDetail(detail)
524
+ const fields = { code: 'progress', phase: name, total, value }
525
+ if (at) useLogger()?.info({ ...fields, detail: at }, '%s: %d/%d — %s', name, value, total, at)
526
+ else useLogger()?.info(fields, '%s: %d/%d', name, value, total)
527
+ }
489
528
 
490
529
  export function trackProgress(name, total) {
491
530
  if (!name || !total) return
@@ -512,14 +551,18 @@ export function trackProgress(name, total) {
512
551
  // --json would have extended that to every machine reading the output.
513
552
  // Losing the graphics is the point; losing the information is not.
514
553
  const drawn = !carriesDocument && Boolean(process.stdout.isTTY) && Boolean(runtime.options.info)
515
- currentBar = { name, total, value: 0, started: Date.now(), drawn, milestone: 0 }
554
+ currentBar = { name, total, value: 0, started: Date.now(), drawn, milestone: 0, detail: null }
516
555
  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
556
  }
519
557
 
520
- export function updateProgress() {
558
+ export function updateProgress(detail) {
521
559
  if (!currentBar) return
522
560
  currentBar.value++
561
+ // The call site hands over whatever identifies the item it is on — a
562
+ // path, an id — and pays one assignment for it. Formatting happens at
563
+ // emit time, at most four times a phase, so naming the work costs the
564
+ // 14k-entity import nothing.
565
+ if (detail !== undefined) currentBar.detail = detail
523
566
  const { name, total, value, drawn } = currentBar
524
567
  if (drawn) {
525
568
  gauge?.show({ section: name, subsection: `${value}/${total}` }, value / total)
@@ -527,11 +570,14 @@ export function updateProgress() {
527
570
  // Quartiles, not every item: a bar redraws in place and costs one
528
571
  // line, a log record does not. 800 documents is 800 lines of noise if
529
572
  // this counts the way the bar does.
573
+ //
574
+ // The milestone advances whether or not the record is emitted. Held
575
+ // back, every quartile the phase passed under the threshold would fire
576
+ // in a burst the moment it crossed.
530
577
  const reached = Math.floor((value / total) * 4)
531
578
  if (reached > currentBar.milestone && value < total) {
532
579
  currentBar.milestone = reached
533
- useLogger()?.info({ code: 'progress', phase: name, total, value },
534
- '%s: %d/%d', name, value, total)
580
+ if (Date.now() - currentBar.started >= PROGRESS_MIN_MS) emitProgress(currentBar)
535
581
  }
536
582
  }
537
583
  if (value >= total) stopProgress()
@@ -541,14 +587,17 @@ export function stopProgress() {
541
587
  if (!currentBar) return
542
588
  const logger = useLogger()
543
589
  const { name, total, value, started } = currentBar
590
+ const ms = Date.now() - started
544
591
  gauge?.hide()
545
592
  // Structured either way, so a machine reading --json's stderr gets the
546
593
  // same facts a person reads off the bar.
594
+ //
595
+ // A phase that did not finish is worth a line at any duration — it is a
596
+ // warning, not progress, and the whole point is that it is unexpected.
547
597
  if (value < total) {
548
598
  logger.warn({ code: 'progress-unfinished', phase: name, total, value, missing: total - value },
549
599
  '%s unfinished: %d', name, total - value)
550
- } else {
551
- const ms = Date.now() - started
600
+ } else if (ms >= PROGRESS_MIN_MS) {
552
601
  logger.info({ code: 'progress-finished', phase: name, total, ms },
553
602
  '%s finished: %d %ds', name, total, Math.round(ms / 1000))
554
603
  }
@@ -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) => {