mikser-io 10.1.0 → 10.3.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.3.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
@@ -44,11 +44,12 @@
44
44
 
45
45
  import path from 'node:path'
46
46
  import { mkdirSync, unlinkSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
47
+ import { createHash } from 'node:crypto'
47
48
  import Database from 'better-sqlite3'
48
49
  import runtime from '../runtime.js'
49
50
  import { isReportOnlyRun } from '../tools.js'
50
51
  import { reportWipe } from '../report.js'
51
- import { onLoaded } from '../lifecycle.js'
52
+ import { onLoaded, onFinalized } from '../lifecycle.js'
52
53
  import packageInfo from '../../package.json' with { type: 'json' }
53
54
 
54
55
  // Local logger resolver — same one-liner engine.js exports as
@@ -232,6 +233,43 @@ export function useDatabase() {
232
233
  return db
233
234
  }
234
235
 
236
+ // What the cache's SHAPE is, as a value that changes only when the cache
237
+ // stops being reusable.
238
+ //
239
+ // It used to be the package version, so every release wiped every
240
+ // deployment's cache and rebuilt from cold — a patch that touched a README
241
+ // discarded 14k entities and re-rendered a site. The version was standing in
242
+ // for "something might have changed", which is true of every release and
243
+ // therefore says nothing.
244
+ //
245
+ // Two things actually invalidate a cache, and both are in here:
246
+ //
247
+ // the SQL — a column, index or table that moved. Hashed from the
248
+ // registered scripts themselves, so it cannot drift from what
249
+ // is applied: the fingerprint and the schema come from one
250
+ // string.
251
+ // the SHAPE — what the rows MEAN, when the SQL is untouched. A change to
252
+ // how inputHash is computed, to the edge kinds in a
253
+ // refClosure, to what a destination is relative to. No parser
254
+ // can see these, so they are a number someone bumps.
255
+ //
256
+ // BUMP DERIVED_SHAPE when a release changes the meaning of anything already
257
+ // stored. Getting that wrong is a silent stale cache, which is the failure
258
+ // this whole subsystem keeps producing — so when in doubt, bump. A needless
259
+ // wipe costs one cold rebuild; a missed one costs a site serving wrong
260
+ // output with every signal green.
261
+ const DERIVED_SHAPE = 1
262
+
263
+ // `<shape>:<sql-hash>` rather than a bare hash: the stored value is read by
264
+ // a human when a wipe happens, and the two halves say WHICH moved.
265
+ function cacheFingerprint(registered) {
266
+ const sql = [...registered.entries()]
267
+ .map(([name, value]) => `${name}\n${schemaEntry(value).sql}`)
268
+ .sort()
269
+ .join('\n')
270
+ return `${DERIVED_SHAPE}:${createHash('sha1').update(sql).digest('hex').slice(0, 12)}`
271
+ }
272
+
235
273
  // Build a sqlite-backed database handle. Exported so tests can exercise
236
274
  // the lifecycle and transaction semantics in isolation (without driving
237
275
  // the full onLoaded chain). The runtime path uses this internally from
@@ -242,6 +280,10 @@ export function createSqliteDatabase({
242
280
  // `--clear` asks for. Removes the file; nothing durable is in it.
243
281
  forceWipe = false,
244
282
  }) {
283
+ // Set at open, written at the first successful finalize. See the note
284
+ // where it is assigned.
285
+ let pendingStamp = null
286
+
245
287
  // Tests inject their own provisioners; the runtime path falls
246
288
  // through to the module-level `provisioners` array that plugins
247
289
  // populate via onProvision() at module-eval.
@@ -292,6 +334,11 @@ export function createSqliteDatabase({
292
334
 
293
335
  const stmtMeta = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
294
336
  const recorded = stmtMeta.get('schema_version')?.value
337
+ // The identity of the cache's shape, not of the release that wrote
338
+ // it. `version` is still carried — into `built_by_version` at stamp
339
+ // time — because "which mikser built this" is worth knowing when
340
+ // reading a cache; it is simply not a reason to throw one away.
341
+ const fingerprint = cacheFingerprint(schemas)
295
342
 
296
343
  // A config change invalidates the cache for the same reason a version
297
344
  // change does: the derived state was computed under different rules.
@@ -323,13 +370,13 @@ export function createSqliteDatabase({
323
370
  const reportOnly = isReportOnlyRun()
324
371
 
325
372
  let upgradedFromVersion = null
326
- if (reportOnly && !forceWipe && ((recorded && recorded !== version) || configChanged)) {
373
+ if (reportOnly && !forceWipe && ((recorded && recorded !== fingerprint) || configChanged)) {
327
374
  logger?.warn(
328
375
  'The cache is stale (%s changed since it was written) and this is a read-only run, '
329
376
  + 'so it was NOT wiped — the answer below describes the last build, which may not '
330
377
  + 'match your sources. Run a build to refresh it.',
331
378
  configChanged ? 'config' : 'schema version')
332
- } else if (configChanged && !(recorded && recorded !== version)) {
379
+ } else if (configChanged && !(recorded && recorded !== fingerprint)) {
333
380
  logger?.warn(
334
381
  'Config changed since the last run. Wiping the cache and rebuilding from sources '
335
382
  + '(files are the source of truth — no source data is affected). The stamp covers %s and '
@@ -337,7 +384,7 @@ export function createSqliteDatabase({
337
384
  runtime.options.config,
338
385
  )
339
386
  }
340
- if (!reportOnly && (forceWipe || (recorded && recorded !== version) || configChanged)) {
387
+ if (!reportOnly && (forceWipe || (recorded && recorded !== fingerprint) || configChanged)) {
341
388
  // Schema mismatch on upgrade or downgrade. Per ADR-0002 the
342
389
  // files on disk are the source of truth and this database
343
390
  // is a derived cache, so the right behavior is to wipe the
@@ -348,10 +395,27 @@ export function createSqliteDatabase({
348
395
  // expect a cold-start rebuild on this run. No data loss
349
396
  // beyond the cache itself; everything in mikser.sqlite is
350
397
  // recoverable from the working folder.
351
- if (recorded && recorded !== version) {
398
+ if (recorded && recorded !== fingerprint) {
399
+ // Names WHICH half moved, because the two mean different
400
+ // things to whoever is reading. A shape bump is a deliberate
401
+ // decision someone made in this release; a SQL change is a
402
+ // table that moved. "stored=10.0.1, current=10.1.0" said
403
+ // neither, and could not — it only ever meant "a release
404
+ // happened".
405
+ const [storedShape, storedSql] = String(recorded).split(':')
406
+ const [shape, sql] = fingerprint.split(':')
407
+ const cause = storedSql === undefined
408
+ ? 'the cache predates schema fingerprints'
409
+ : storedShape !== shape && storedSql !== sql
410
+ ? 'the schema tables AND the meaning of what is stored both changed'
411
+ : storedShape !== shape
412
+ ? 'this release changed the meaning of what is already stored'
413
+ : 'the schema tables changed'
352
414
  logger?.warn(
353
- 'Database schema mismatch: stored=%s, current=%s. Wiping the cache and rebuilding from sources (files are the source of truth — no source data is affected).',
354
- recorded, version,
415
+ 'Cache shape changed — %s (stored=%s, current=%s, this is mikser %s). Wiping the cache '
416
+ + 'and rebuilding from sources (files are the source of truth — no source data is '
417
+ + 'affected). A release that changes neither reuses the cache.',
418
+ cause, recorded, fingerprint, version,
355
419
  )
356
420
  } else if (forceWipe) {
357
421
  logger?.info('Clearing the cache and rebuilding from sources.')
@@ -361,8 +425,8 @@ export function createSqliteDatabase({
361
425
  // the same output whether the version moved, the config moved, or
362
426
  // someone passed --clear.
363
427
  reportWipe(
364
- recorded && recorded !== version ? 'version' : forceWipe ? 'clear' : 'config',
365
- recorded && recorded !== version ? { from: recorded, to: version } : {},
428
+ recorded && recorded !== fingerprint ? 'version' : forceWipe ? 'clear' : 'config',
429
+ recorded && recorded !== fingerprint ? { from: recorded, to: fingerprint } : {},
366
430
  )
367
431
 
368
432
  // Unlink, rather than dropping table by table.
@@ -391,9 +455,24 @@ export function createSqliteDatabase({
391
455
  handle = new Database(dbPath)
392
456
  setupConnection()
393
457
  }
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)
458
+ // Stamped when a cycle FINISHES, not when the database opens.
459
+ //
460
+ // The stamp is the only record of "this cache was rebuilt for this
461
+ // version", and writing it at open made it a record of "this cache
462
+ // was opened", which is a different and much weaker claim. A wipe
463
+ // followed by an interrupted rebuild left the version current and the
464
+ // cache half-built: the import had finished, so the catalog matched
465
+ // disk, but nothing had rendered, so there were no snapshots and the
466
+ // output folder still held the previous build. Every later start read
467
+ // a matching version, wiped nothing, correctly reported "N unchanged"
468
+ // and rendered nothing — a site frozen on the last good output, green
469
+ // on every signal, recoverable only by --force.
470
+ //
471
+ // Deferred, the same sequence self-heals: the interrupted run leaves
472
+ // no stamp, so the next start sees a mismatch and rebuilds properly.
473
+ // config_checksum goes with it for the same reason — a config change
474
+ // that half-applied must not look applied.
475
+ pendingStamp = { fingerprint, version, config: currentConfig }
397
476
 
398
477
  // Build provisioning context. firstRun is true when the file
399
478
  // didn't exist before this open OR when the schema mismatch
@@ -444,6 +523,43 @@ export function createSqliteDatabase({
444
523
  }
445
524
  }
446
525
 
526
+ // Did the last rebuild finish?
527
+ //
528
+ // Now that the stamp means "a cycle completed", its ABSENCE beside a
529
+ // populated catalog is a fact worth acting on: entities were imported
530
+ // and nothing ever finalized. The catalog matches disk, so every gate
531
+ // that reasons about inputs correctly says "unchanged"; the manifest
532
+ // is empty, so nothing has been rendered; and the output folder still
533
+ // holds whatever the last complete build wrote, because a cache wipe
534
+ // unlinks the database and nothing else.
535
+ //
536
+ // No layer can see this on its own. The source gate's evidence is
537
+ // sound, the dispatcher seeds from a journal that was discarded with
538
+ // the interrupted run, and the render gate — which would answer
539
+ // `never-rendered` — is never asked, because nothing dispatches to it.
540
+ // So it is declared as an override, and invalidation.js hands it to
541
+ // every gate at once.
542
+ //
543
+ // An empty catalog here is an ordinary first run or a completed wipe:
544
+ // nothing to reconcile, and emptiness already opens every gate.
545
+ if (!handle.prepare('SELECT value FROM mikser_meta WHERE key = ?').get('schema_version')) {
546
+ const table = handle.prepare(
547
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='mikser_entities'").get()
548
+ const entities = table
549
+ ? handle.prepare('SELECT count(*) AS count FROM mikser_entities').get()?.count ?? 0
550
+ : 0
551
+ if (entities > 0) {
552
+ logger?.warn(
553
+ 'The last rebuild did not finish — %d entities were imported and no cycle completed, '
554
+ + 'so the catalog describes your sources while nothing has been rendered from them and '
555
+ + 'the output folder still holds the previous build. Rebuilding everything on this run. '
556
+ + '(A cache wipe followed by a restart mid-cycle does this; without the check the site '
557
+ + 'stays on the old output and every build reports "unchanged".)',
558
+ entities,
559
+ )
560
+ runtime.options.cacheRebuildInterrupted = true
561
+ }
562
+ }
447
563
  }
448
564
 
449
565
  function close() {
@@ -467,6 +583,21 @@ export function createSqliteDatabase({
467
583
  path: dbPath,
468
584
  open,
469
585
  close,
586
+ // Called once a cycle has finalized — see pendingStamp. Idempotent:
587
+ // the second cycle of a watch run has nothing left to write.
588
+ commitStamp() {
589
+ if (!pendingStamp || !handle) return false
590
+ const stamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
591
+ stamp.run('schema_version', pendingStamp.fingerprint)
592
+ // Which release wrote this cache. Recorded because it is the
593
+ // first thing a person wants when reading one, and deliberately
594
+ // NOT compared against anything — that was the bug.
595
+ stamp.run('built_by_version', pendingStamp.version)
596
+ if (pendingStamp.config) stamp.run('config_checksum', pendingStamp.config)
597
+ pendingStamp = null
598
+ runtime.options.cacheRebuildInterrupted = false
599
+ return true
600
+ },
470
601
  get isOpen() { return handle !== null },
471
602
  // Provisioning context from the most recent open. Plugins'
472
603
  // onLoaded handlers can read this without subscribing to
@@ -492,6 +623,20 @@ export function createSqliteDatabase({
492
623
  }
493
624
  }
494
625
 
626
+ // The cache is stamped for this version only once a cycle has run to the
627
+ // end. Registered at module level rather than per-open so a watch server
628
+ // that re-opens nothing still stamps its first completed cycle, and so the
629
+ // hook cannot accumulate across opens.
630
+ //
631
+ // onFinalized, not onFinalize: the manifest writes this cycle's snapshots in
632
+ // onFinalize, and the stamp claims that work happened. Claiming it one hook
633
+ // early would reintroduce the failure in miniature.
634
+ onFinalized(async () => {
635
+ if (db?.commitStamp?.()) {
636
+ useLogger()?.debug('Cache stamped — a full cycle completed for this schema version')
637
+ }
638
+ })
639
+
495
640
  onLoaded(async () => {
496
641
  if (db?.isOpen) return // multi-cycle watch mode — keep the open connection
497
642
 
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
  //