mikser-io 10.1.0 → 10.2.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.1.0",
3
+ "version": "10.2.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -48,7 +48,7 @@ import Database from 'better-sqlite3'
48
48
  import runtime from '../runtime.js'
49
49
  import { isReportOnlyRun } from '../tools.js'
50
50
  import { reportWipe } from '../report.js'
51
- import { onLoaded } from '../lifecycle.js'
51
+ import { onLoaded, onFinalized } from '../lifecycle.js'
52
52
  import packageInfo from '../../package.json' with { type: 'json' }
53
53
 
54
54
  // Local logger resolver — same one-liner engine.js exports as
@@ -242,6 +242,10 @@ export function createSqliteDatabase({
242
242
  // `--clear` asks for. Removes the file; nothing durable is in it.
243
243
  forceWipe = false,
244
244
  }) {
245
+ // Set at open, written at the first successful finalize. See the note
246
+ // where it is assigned.
247
+ let pendingStamp = null
248
+
245
249
  // Tests inject their own provisioners; the runtime path falls
246
250
  // through to the module-level `provisioners` array that plugins
247
251
  // populate via onProvision() at module-eval.
@@ -391,9 +395,24 @@ export function createSqliteDatabase({
391
395
  handle = new Database(dbPath)
392
396
  setupConnection()
393
397
  }
394
- const stmtStamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
395
- stmtStamp.run('schema_version', version)
396
- if (currentConfig) stmtStamp.run('config_checksum', currentConfig)
398
+ // Stamped when a cycle FINISHES, not when the database opens.
399
+ //
400
+ // The stamp is the only record of "this cache was rebuilt for this
401
+ // version", and writing it at open made it a record of "this cache
402
+ // was opened", which is a different and much weaker claim. A wipe
403
+ // followed by an interrupted rebuild left the version current and the
404
+ // cache half-built: the import had finished, so the catalog matched
405
+ // disk, but nothing had rendered, so there were no snapshots and the
406
+ // output folder still held the previous build. Every later start read
407
+ // a matching version, wiped nothing, correctly reported "N unchanged"
408
+ // and rendered nothing — a site frozen on the last good output, green
409
+ // on every signal, recoverable only by --force.
410
+ //
411
+ // Deferred, the same sequence self-heals: the interrupted run leaves
412
+ // no stamp, so the next start sees a mismatch and rebuilds properly.
413
+ // config_checksum goes with it for the same reason — a config change
414
+ // that half-applied must not look applied.
415
+ pendingStamp = { version, config: currentConfig }
397
416
 
398
417
  // Build provisioning context. firstRun is true when the file
399
418
  // didn't exist before this open OR when the schema mismatch
@@ -444,6 +463,43 @@ export function createSqliteDatabase({
444
463
  }
445
464
  }
446
465
 
466
+ // Did the last rebuild finish?
467
+ //
468
+ // Now that the stamp means "a cycle completed", its ABSENCE beside a
469
+ // populated catalog is a fact worth acting on: entities were imported
470
+ // and nothing ever finalized. The catalog matches disk, so every gate
471
+ // that reasons about inputs correctly says "unchanged"; the manifest
472
+ // is empty, so nothing has been rendered; and the output folder still
473
+ // holds whatever the last complete build wrote, because a cache wipe
474
+ // unlinks the database and nothing else.
475
+ //
476
+ // No layer can see this on its own. The source gate's evidence is
477
+ // sound, the dispatcher seeds from a journal that was discarded with
478
+ // the interrupted run, and the render gate — which would answer
479
+ // `never-rendered` — is never asked, because nothing dispatches to it.
480
+ // So it is declared as an override, and invalidation.js hands it to
481
+ // every gate at once.
482
+ //
483
+ // An empty catalog here is an ordinary first run or a completed wipe:
484
+ // nothing to reconcile, and emptiness already opens every gate.
485
+ if (!handle.prepare('SELECT value FROM mikser_meta WHERE key = ?').get('schema_version')) {
486
+ const table = handle.prepare(
487
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='mikser_entities'").get()
488
+ const entities = table
489
+ ? handle.prepare('SELECT count(*) AS count FROM mikser_entities').get()?.count ?? 0
490
+ : 0
491
+ if (entities > 0) {
492
+ logger?.warn(
493
+ 'The last rebuild did not finish — %d entities were imported and no cycle completed, '
494
+ + 'so the catalog describes your sources while nothing has been rendered from them and '
495
+ + 'the output folder still holds the previous build. Rebuilding everything on this run. '
496
+ + '(A cache wipe followed by a restart mid-cycle does this; without the check the site '
497
+ + 'stays on the old output and every build reports "unchanged".)',
498
+ entities,
499
+ )
500
+ runtime.options.cacheRebuildInterrupted = true
501
+ }
502
+ }
447
503
  }
448
504
 
449
505
  function close() {
@@ -467,6 +523,17 @@ export function createSqliteDatabase({
467
523
  path: dbPath,
468
524
  open,
469
525
  close,
526
+ // Called once a cycle has finalized — see pendingStamp. Idempotent:
527
+ // the second cycle of a watch run has nothing left to write.
528
+ commitStamp() {
529
+ if (!pendingStamp || !handle) return false
530
+ const stamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
531
+ stamp.run('schema_version', pendingStamp.version)
532
+ if (pendingStamp.config) stamp.run('config_checksum', pendingStamp.config)
533
+ pendingStamp = null
534
+ runtime.options.cacheRebuildInterrupted = false
535
+ return true
536
+ },
470
537
  get isOpen() { return handle !== null },
471
538
  // Provisioning context from the most recent open. Plugins'
472
539
  // onLoaded handlers can read this without subscribing to
@@ -492,6 +559,20 @@ export function createSqliteDatabase({
492
559
  }
493
560
  }
494
561
 
562
+ // The cache is stamped for this version only once a cycle has run to the
563
+ // end. Registered at module level rather than per-open so a watch server
564
+ // that re-opens nothing still stamps its first completed cycle, and so the
565
+ // hook cannot accumulate across opens.
566
+ //
567
+ // onFinalized, not onFinalize: the manifest writes this cycle's snapshots in
568
+ // onFinalize, and the stamp claims that work happened. Claiming it one hook
569
+ // early would reintroduce the failure in miniature.
570
+ onFinalized(async () => {
571
+ if (db?.commitStamp?.()) {
572
+ useLogger()?.debug('Cache stamped — a full cycle completed for this schema version')
573
+ }
574
+ })
575
+
495
576
  onLoaded(async () => {
496
577
  if (db?.isOpen) return // multi-cycle watch mode — keep the open connection
497
578
 
package/src/engine.js CHANGED
@@ -1575,10 +1575,34 @@ The full version, with what each code means: docs/diagnostics.md`)
1575
1575
  // undefined here. The logger has no such problem — it writes during the
1576
1576
  // run, by which time options exist.
1577
1577
  const quietStdout = ['--json', '--tool', '--tools'].some(flag => process.argv.includes(flag))
1578
+ // Through the logger when nobody is watching, so it gets a timestamp.
1579
+ //
1580
+ // This line marks a process start, which in a supervisor's log is the
1581
+ // most useful thing on the page — it is how a restart is found at all.
1582
+ // Written straight to the stream it was undated and, worse, carried its
1583
+ // own hardcoded escapes into a file that no terminal would ever render.
1584
+ //
1585
+ // The decorated form stays for a terminal, where it is a banner rather
1586
+ // than a record.
1578
1587
  if (runtime.options?.json || quietStdout) {
1588
+ // Written straight to stderr, NOT through the logger: the logger picks
1589
+ // its sink per-write from runtime.options.json, which commander has
1590
+ // not parsed yet — the same reason quietStdout reads argv above. A
1591
+ // banner routed through it here lands on stdout and turns the
1592
+ // document into a parse error, which is the failure this branch was
1593
+ // added to prevent in the first place.
1579
1594
  process.stderr.write(`mikser. ${packageInfo.version}\n`)
1580
- } else {
1595
+ } else if (process.stdout.isTTY && !process.env.NO_COLOR) {
1581
1596
  console.info('\x1b[1mmikser\x1b[22;5;38;2;255;63;0m.\x1b[0m %s\n', packageInfo.version)
1597
+ } else {
1598
+ // Redirected: through the logger, so the line that marks a process
1599
+ // start carries a timestamp. In a supervisor's log that is the most
1600
+ // useful line on the page — it is how a restart is found at all — and
1601
+ // written straight to the stream it was undated and carried its own
1602
+ // escapes into a file no terminal will render.
1603
+ const logger = useLogger()
1604
+ if (logger) logger.info('mikser. %s', packageInfo.version)
1605
+ else process.stdout.write(`mikser. ${packageInfo.version}\n`)
1582
1606
  }
1583
1607
  return runtime
1584
1608
  }
@@ -50,6 +50,7 @@ export const REASON = Object.freeze({
50
50
  FORCE: 'force',
51
51
  CACHE_INVALIDATED: 'cache-invalidated',
52
52
  RELOAD: 'reload',
53
+ REBUILD_INTERRUPTED: 'rebuild-interrupted',
53
54
  OUTPUT_MISSING: 'output-missing',
54
55
  // Evidence — each layer's business, named here so the vocabulary is
55
56
  // legible as a whole.
@@ -130,6 +131,12 @@ export function bypassReason({ reload = false, id } = {}) {
130
131
  if (reload) return REASON.RELOAD
131
132
  if (runtime.options?.force) return REASON.FORCE
132
133
  if (runtime.catalog?.cacheInvalidated) return REASON.CACHE_INVALIDATED
134
+ // The previous rebuild imported entities and never finalized, so the
135
+ // catalog describes sources that nothing has rendered. Set by
136
+ // database/index.js at open, cleared when a cycle stamps the cache.
137
+ // Declared here so it reaches every gate — which is the whole reason
138
+ // this module exists, and this is the first override added since.
139
+ if (runtime.options?.cacheRebuildInterrupted) return REASON.REBUILD_INTERRUPTED
133
140
  if (id !== undefined && missingOutputIds().has(id)) return REASON.OUTPUT_MISSING
134
141
  return null
135
142
  }
package/src/logger.js CHANGED
@@ -154,16 +154,44 @@ function createTerminalStream() {
154
154
  // trace} resolves to, defaulting to 'info'.
155
155
  export function createMikserLogger(level = 'info') {
156
156
  const terminalStream = createTerminalStream()
157
+
158
+ // A terminal is watched as it happens. A file is read afterwards.
159
+ //
160
+ // The minimal format below is right for the first and wrong for the
161
+ // second, and until now it was used for both — so a supervisor's log was
162
+ // a wall of undated lines wearing ANSI escapes. Reconstructing an
163
+ // incident from one meant ordering events by file mtimes and git commit
164
+ // dates because the build's own log could not say when anything happened,
165
+ // and every excerpt had to be piped through sed to be readable.
166
+ //
167
+ // Decided from the stream these lines actually land on, which is stderr
168
+ // under --json / --tool (stdout carries the document there). The logger is
169
+ // rebuilt at onLoad, by which point those options are parsed, so the
170
+ // second construction gets it right even if the first cannot.
171
+ //
172
+ // Same signal the progress bar already uses — a gauge is pointless in a
173
+ // file for the same reason a timestamp is pointless on a terminal.
174
+ const target = (runtime.options?.json || runtime.options?.tool || runtime.options?.tools)
175
+ ? process.stderr : process.stdout
176
+ const attended = Boolean(target.isTTY)
177
+
157
178
  const prettyStream = pretty({
158
179
  destination: terminalStream,
159
- colorize: true,
160
- // apt-like minimal format: hide timestamp / pid / hostname, and
161
- // suppress the level prefix entirely via a customPrettifier
162
- // that returns an empty string. The icon prepended by
163
- // messageFormat (🟡 / 🔴 / 🟢 / …) is what signals level. The
164
- // raw pino record still carries `level`, so third-party
165
- // transports get full structured data.
166
- ignore: 'pid,hostname,time',
180
+ // NO_COLOR is honoured on a terminal too; nobody wants escapes in a
181
+ // file regardless.
182
+ colorize: attended && !process.env.NO_COLOR,
183
+ // `SYS:standard` carries the date, milliseconds AND the UTC offset.
184
+ // The offset is not decoration: the incident that prompted this
185
+ // needed a container's clock lined up against commit dates in
186
+ // another zone, and a bare wall-clock time cannot answer that.
187
+ ...(attended ? {} : { translateTime: 'SYS:standard' }),
188
+ // apt-like minimal format: hide pid / hostname, and suppress the
189
+ // level prefix entirely via a customPrettifier that returns an empty
190
+ // string. The icon prepended by messageFormat (🟡 / 🔴 / 🟢 / …) is
191
+ // what signals level. The raw pino record still carries `level`, so
192
+ // third-party transports get full structured data. `time` is dropped
193
+ // only when someone is watching.
194
+ ignore: attended ? 'pid,hostname,time' : 'pid,hostname',
167
195
  // The terminal gets the SENTENCE; the structured fields go to the
168
196
  // report and to transports.
169
197
  //