mikser-io 10.0.4 → 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/index.js +1 -0
- package/package.json +1 -1
- package/src/database/index.js +85 -4
- package/src/engine.js +25 -1
- package/src/explain.js +3 -5
- package/src/invalidation.js +165 -0
- package/src/logger.js +36 -8
- package/src/manifest.js +18 -75
- package/src/plugins/assets.js +6 -3
- package/src/plugins/files.js +3 -6
- package/src/source.js +9 -22
- package/src/utils.js +0 -23
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as runtime } from './src/runtime.js'
|
|
2
2
|
export * as constants from './src/constants.js'
|
|
3
3
|
export * from './src/utils.js'
|
|
4
|
+
export * from './src/invalidation.js'
|
|
4
5
|
export * from './src/auth.js'
|
|
5
6
|
export * from './src/roles.js'
|
|
6
7
|
export * from './src/inventory.js'
|
package/package.json
CHANGED
package/src/database/index.js
CHANGED
|
@@ -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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
}
|
package/src/explain.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Follows --audit-output's shape: report and exit, no build phases run.
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
13
|
-
import {
|
|
13
|
+
import { outputMissing } from './invalidation.js'
|
|
14
14
|
import { filterKey } from './track.js'
|
|
15
15
|
import { findEntity, findEntities, findById } from './catalog.js'
|
|
16
16
|
import runtime from './runtime.js'
|
|
@@ -141,7 +141,7 @@ export async function explain(reference) {
|
|
|
141
141
|
// attempt — if the last render threw, that is the more useful sentence,
|
|
142
142
|
// and it explains the absence too.
|
|
143
143
|
const missingDestinations = snapshots
|
|
144
|
-
.filter(snap =>
|
|
144
|
+
.filter(snap => outputMissing(snap.destination))
|
|
145
145
|
.map(snap => snap.destination)
|
|
146
146
|
|
|
147
147
|
// The catalog is as of the LAST BUILD. If the file has been edited since,
|
|
@@ -244,9 +244,7 @@ export async function explain(reference) {
|
|
|
244
244
|
// re-renders. --explain contradicting the build is worse than
|
|
245
245
|
// --explain being incomplete, because it is the tool someone
|
|
246
246
|
// reaches for when the build has already surprised them.
|
|
247
|
-
missing: snap.destination
|
|
248
|
-
? !existsSync(resolveOutputPath(snap.destination))
|
|
249
|
-
: false,
|
|
247
|
+
missing: outputMissing(snap.destination),
|
|
250
248
|
outputHash: snap.outputHash ?? null,
|
|
251
249
|
parent: snap.parent ?? null,
|
|
252
250
|
// Which keys of its OWN meta the render read, and which keys it
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Should this work be redone?
|
|
2
|
+
//
|
|
3
|
+
// One question, asked at four layers with different evidence in hand:
|
|
4
|
+
//
|
|
5
|
+
// source.js a file's checksum against the catalog's
|
|
6
|
+
// manifest a recorded hash, for the layouts dispatcher's seeding
|
|
7
|
+
// manifest a snapshot plus its refClosure, at the render gate
|
|
8
|
+
// assets a `.md5` marker at the current preset revision
|
|
9
|
+
//
|
|
10
|
+
// The evidence differs and belongs to each layer. What does NOT differ is
|
|
11
|
+
// what OVERRIDES the evidence — and that is what this module owns, because
|
|
12
|
+
// it was written four times and drifted the moment a fifth override was
|
|
13
|
+
// added to one of them.
|
|
14
|
+
//
|
|
15
|
+
// The drift, concretely: `rm -rf out` changes no input, so every layer
|
|
16
|
+
// answered "unchanged" and a build rendered nothing, printed no `Rendered:`
|
|
17
|
+
// line and exited 0 over an empty output folder. Adding an output-existence
|
|
18
|
+
// check to the render gate fixed nothing, because the source gate had
|
|
19
|
+
// already dropped the entity; adding it there fixed the site but not image
|
|
20
|
+
// derivatives, because files.js held a private copy of that gate; and fixing
|
|
21
|
+
// that still left the assets marker, which had never asked about the file at
|
|
22
|
+
// all. Five gates, one question, four implementations of the override rules.
|
|
23
|
+
//
|
|
24
|
+
// So the rule is: a layer may own what it KNOWS. It may not own what
|
|
25
|
+
// overrides it. Anything that makes a gate not apply — --force, a wiped
|
|
26
|
+
// cache, a reload event, an output that is gone — is declared here once, and
|
|
27
|
+
// a new one added here reaches every gate that ever asks.
|
|
28
|
+
//
|
|
29
|
+
// Deliberately NOT moved here: the dependency-graph walk in
|
|
30
|
+
// manifest.skipDecision. That reasons over snapshots, refClosures and sift
|
|
31
|
+
// filters the manifest owns, and hauling it out would trade a real coupling
|
|
32
|
+
// for a fake one. It asks this module what overrides its answer, which is the
|
|
33
|
+
// part that was duplicated.
|
|
34
|
+
//
|
|
35
|
+
// A leaf module: runtime and node builtins only. It reads `runtime.manifest`
|
|
36
|
+
// lazily rather than importing it, the way every other consumer of engine
|
|
37
|
+
// state does, so the manifest can import this without a cycle.
|
|
38
|
+
|
|
39
|
+
import path from 'node:path'
|
|
40
|
+
import { existsSync } from 'node:fs'
|
|
41
|
+
import runtime from './runtime.js'
|
|
42
|
+
|
|
43
|
+
// The vocabulary, in one place because it is asserted against.
|
|
44
|
+
//
|
|
45
|
+
// `--json` carries these strings out to callers and the scenario suite
|
|
46
|
+
// matches on them, so they are API. Defined here rather than in the gate
|
|
47
|
+
// that happens to emit each one, so the set can be read as a set.
|
|
48
|
+
export const REASON = Object.freeze({
|
|
49
|
+
// Overrides — this module's business.
|
|
50
|
+
FORCE: 'force',
|
|
51
|
+
CACHE_INVALIDATED: 'cache-invalidated',
|
|
52
|
+
RELOAD: 'reload',
|
|
53
|
+
REBUILD_INTERRUPTED: 'rebuild-interrupted',
|
|
54
|
+
OUTPUT_MISSING: 'output-missing',
|
|
55
|
+
// Evidence — each layer's business, named here so the vocabulary is
|
|
56
|
+
// legible as a whole.
|
|
57
|
+
UNCHANGED: 'unchanged',
|
|
58
|
+
NEVER_RENDERED: 'never-rendered',
|
|
59
|
+
INPUTS_CHANGED: 'inputs-changed',
|
|
60
|
+
REF_CHANGED: 'ref-changed',
|
|
61
|
+
QUERY_MATCHED: 'query-matched',
|
|
62
|
+
CACHE_DISABLED: 'cache-disabled',
|
|
63
|
+
RETRY_FAILED: 'retry-failed',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Where an output lives, given what a snapshot recorded.
|
|
67
|
+
//
|
|
68
|
+
// Destinations come in two shapes and both are legitimate: output-relative
|
|
69
|
+
// for a rendered page (`/index.html`), absolute for a derivative the assets
|
|
70
|
+
// plugin places itself. Resolving relative-first and falling back to the
|
|
71
|
+
// literal path covers both without the caller having to know which it holds.
|
|
72
|
+
//
|
|
73
|
+
// Returns the joined path even when nothing exists there, so a caller can
|
|
74
|
+
// report WHICH path it looked for — "missing" without a path is a fact
|
|
75
|
+
// nobody can act on.
|
|
76
|
+
export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
|
|
77
|
+
if (!destination) return undefined
|
|
78
|
+
const joined = path.join(outputFolder ?? '', destination)
|
|
79
|
+
if (existsSync(joined)) return joined
|
|
80
|
+
if (path.isAbsolute(destination) && existsSync(destination)) return destination
|
|
81
|
+
return joined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Is the file this destination names gone?
|
|
85
|
+
export function outputMissing(destination) {
|
|
86
|
+
if (!destination) return false
|
|
87
|
+
return !existsSync(resolveOutputPath(destination))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Memoized for the cycle.
|
|
91
|
+
//
|
|
92
|
+
// Every gate asks, and they all ask before anything has rendered, so one walk
|
|
93
|
+
// answers all of them. Dropped at the end of onFinalize, where this cycle's
|
|
94
|
+
// renders are recorded and the answer stops being true — not at the start of
|
|
95
|
+
// the next cycle, because in watch mode there may not be one for hours and a
|
|
96
|
+
// stale set held that long would re-dispatch entities it saw as missing.
|
|
97
|
+
let cachedMissingOutputs = null
|
|
98
|
+
|
|
99
|
+
// The entity ids whose last render wrote a file that is no longer on disk.
|
|
100
|
+
//
|
|
101
|
+
// One stat per recorded snapshot. The syscalls are ~2.2ms per 1842 paths and
|
|
102
|
+
// cost the same whether the files are there or not; at the build level it does
|
|
103
|
+
// not register — warm rebuilds of the 10k perf corpus ran 3.36s median with
|
|
104
|
+
// this against 3.56s without, i.e. nominally faster, which is only to say the
|
|
105
|
+
// difference is inside the run-to-run spread. Re-measure with
|
|
106
|
+
// `npm run test:perf` before trusting a claim that it got slower.
|
|
107
|
+
export function missingOutputIds() {
|
|
108
|
+
if (cachedMissingOutputs) return cachedMissingOutputs
|
|
109
|
+
const missing = new Set()
|
|
110
|
+
for (const snapshot of runtime.manifest?.all?.() ?? []) {
|
|
111
|
+
if (!snapshot.destination) continue
|
|
112
|
+
if (outputMissing(snapshot.destination)) missing.add(snapshot.id)
|
|
113
|
+
}
|
|
114
|
+
cachedMissingOutputs = missing
|
|
115
|
+
return missing
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function forgetMissingOutputs() {
|
|
119
|
+
cachedMissingOutputs = null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// What overrides a gate's own evidence, or null when nothing does.
|
|
123
|
+
//
|
|
124
|
+
// Callers pass the evidence they hold: `reload` if they are a watch event,
|
|
125
|
+
// `id` if they are gating an entity whose outputs are recorded. Passing
|
|
126
|
+
// neither asks only the universal question, which is what the render gate
|
|
127
|
+
// wants — it checks the output separately and later, so that an entity whose
|
|
128
|
+
// inputs ALSO moved reports `inputs-changed` rather than losing that detail
|
|
129
|
+
// to a broader answer.
|
|
130
|
+
export function bypassReason({ reload = false, id } = {}) {
|
|
131
|
+
if (reload) return REASON.RELOAD
|
|
132
|
+
if (runtime.options?.force) return REASON.FORCE
|
|
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
|
|
140
|
+
if (id !== undefined && missingOutputIds().has(id)) return REASON.OUTPUT_MISSING
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Is every entity being presented for evaluation this cycle?
|
|
145
|
+
//
|
|
146
|
+
// The same fact as bypassReason, asked for a different purpose: a "declared X
|
|
147
|
+
// matched nothing" warning is only worth printing when everything was looked
|
|
148
|
+
// at. On an incremental cycle a pattern legitimately matches nothing in a run
|
|
149
|
+
// of two, and a warning that always fires is one people filter out — taking
|
|
150
|
+
// the real instance with it.
|
|
151
|
+
//
|
|
152
|
+
// `firstRun` counts here and not in bypassReason because there is nothing to
|
|
153
|
+
// bypass on a first run: no prior checksum, no snapshot, so every gate opens
|
|
154
|
+
// on its own evidence.
|
|
155
|
+
//
|
|
156
|
+
// Takes the runtime rather than reading the singleton, so a plugin passes the
|
|
157
|
+
// one it was injected with — the singleton in a real build, the harness's
|
|
158
|
+
// stand-in under test.
|
|
159
|
+
export function isFullCycle(engineRuntime = runtime) {
|
|
160
|
+
return !!(
|
|
161
|
+
engineRuntime?.options?.force
|
|
162
|
+
|| engineRuntime?.options?.firstRun
|
|
163
|
+
|| engineRuntime?.catalog?.cacheInvalidated
|
|
164
|
+
)
|
|
165
|
+
}
|
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
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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
|
//
|
package/src/manifest.js
CHANGED
|
@@ -60,6 +60,9 @@ import { extractRefs, inputHashOf, inputPartsOf, diffInputParts, lookupKeys } fr
|
|
|
60
60
|
import { filterKey } from './track.js'
|
|
61
61
|
import { findById, findEntities } from './catalog.js'
|
|
62
62
|
import { useDatabase, registerSchema } from './database/index.js'
|
|
63
|
+
import {
|
|
64
|
+
REASON, bypassReason, resolveOutputPath, outputMissing, missingOutputIds, forgetMissingOutputs,
|
|
65
|
+
} from './invalidation.js'
|
|
63
66
|
|
|
64
67
|
// Schema registration. Applied at db.open(). PRIMARY KEY (id,
|
|
65
68
|
// destination) — leading id column means `WHERE id = ?` queries use the
|
|
@@ -284,13 +287,6 @@ function snapToRow(snap) {
|
|
|
284
287
|
// becomes two symptoms — one loud (every asset reported missing, at its
|
|
285
288
|
// real and present path) and one silent (no outputHash recorded, leaving
|
|
286
289
|
// most snapshots presence-checked only).
|
|
287
|
-
export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
|
|
288
|
-
if (!destination) return undefined
|
|
289
|
-
const joined = path.join(outputFolder ?? '', destination)
|
|
290
|
-
if (existsSync(joined)) return joined
|
|
291
|
-
if (path.isAbsolute(destination) && existsSync(destination)) return destination
|
|
292
|
-
return joined
|
|
293
|
-
}
|
|
294
290
|
|
|
295
291
|
async function hashOutputFile(destination) {
|
|
296
292
|
const filePath = resolveOutputPath(destination)
|
|
@@ -372,11 +368,6 @@ export async function sourcesOf(destination) {
|
|
|
372
368
|
export function createManifest(db) {
|
|
373
369
|
if (!db) throw new Error('createManifest: db is required')
|
|
374
370
|
|
|
375
|
-
// Per-instance, not module-level: tests build their own manifest via
|
|
376
|
-
// createManifest(db), and a shared memo would leak one test's output
|
|
377
|
-
// folder into the next.
|
|
378
|
-
let cachedMissingOutputs = null
|
|
379
|
-
|
|
380
371
|
const stmtCollisions = db.prepare(`
|
|
381
372
|
SELECT destination, count(*) AS n, group_concat(id) AS ids
|
|
382
373
|
FROM mikser_snapshots
|
|
@@ -682,8 +673,12 @@ export function createManifest(db) {
|
|
|
682
673
|
// That is the situation --force exists for — the invalidation
|
|
683
674
|
// graph being under suspicion — including where the preset
|
|
684
675
|
// no-match warning tells the operator to use it.
|
|
685
|
-
|
|
686
|
-
|
|
676
|
+
// --force and a wiped cache both mean "ignore what you think you
|
|
677
|
+
// know", and both are declared in invalidation.js so a third one
|
|
678
|
+
// added there reaches this gate without being remembered.
|
|
679
|
+
const override = bypassReason()
|
|
680
|
+
if (override) return { skip: false, reason: override }
|
|
681
|
+
if (entity?.meta?.cache === false) return { skip: false, reason: REASON.CACHE_DISABLED }
|
|
687
682
|
// A render whose last attempt threw must be retried, and checked
|
|
688
683
|
// before anything else: every other branch reasons about hashes,
|
|
689
684
|
// and the hashes are consistent — the entity did not change, the
|
|
@@ -737,15 +732,13 @@ export function createManifest(db) {
|
|
|
737
732
|
// rather than in the render loop because this is the function that
|
|
738
733
|
// owns the word: anything asking shouldSkip() gets the same answer.
|
|
739
734
|
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
// the same
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (snapshot.destination && !existsSync(resolveOutputPath(snapshot.destination))) {
|
|
748
|
-
return { skip: false, reason: 'output-missing', destination: snapshot.destination }
|
|
735
|
+
// Asked of invalidation.js rather than answered here, so that
|
|
736
|
+
// auditOutput, the source gate and the assets marker resolve a
|
|
737
|
+
// destination the same way. The two disagreeing is the bug —
|
|
738
|
+
// `--audit-output` finding what the build just called clean is
|
|
739
|
+
// what sends people to this function in the first place.
|
|
740
|
+
if (outputMissing(snapshot.destination)) {
|
|
741
|
+
return { skip: false, reason: REASON.OUTPUT_MISSING, destination: snapshot.destination }
|
|
749
742
|
}
|
|
750
743
|
if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
|
|
751
744
|
const sourceLang = entity?.meta?.lang ?? null
|
|
@@ -1031,7 +1024,7 @@ export function createManifest(db) {
|
|
|
1031
1024
|
// rediscover the same caveat. missingOutputIds() is memoized for the
|
|
1032
1025
|
// cycle, so this shares the walk the source gate already paid for.
|
|
1033
1026
|
recordedHashes() {
|
|
1034
|
-
const missingOutputs =
|
|
1027
|
+
const missingOutputs = missingOutputIds()
|
|
1035
1028
|
const map = new Map()
|
|
1036
1029
|
for (const row of stmtEntityInputHashes.iterate()) {
|
|
1037
1030
|
if (missingOutputs.has(row.id)) continue
|
|
@@ -1106,56 +1099,6 @@ export function createManifest(db) {
|
|
|
1106
1099
|
return edges
|
|
1107
1100
|
},
|
|
1108
1101
|
|
|
1109
|
-
// The entity ids whose last render wrote a file that is no longer
|
|
1110
|
-
// on disk. A dispatch hint, the same shape as queryAffected: the
|
|
1111
|
-
// source checksum gate consults it so those entities are re-emitted
|
|
1112
|
-
// instead of being short-circuited as unchanged.
|
|
1113
|
-
//
|
|
1114
|
-
// This exists because the gate that stops `rm -rf out` from being
|
|
1115
|
-
// noticed is the FIRST one, not the last. An unchanged file never
|
|
1116
|
-
// gets a CREATE, so it never reaches the journal, so the render loop
|
|
1117
|
-
// never sees it and manifest.skipDecision — which does check for a
|
|
1118
|
-
// missing output — is never asked. Fixing only the render gate makes
|
|
1119
|
-
// the build correct for entities that get that far and changes
|
|
1120
|
-
// nothing for the case in the report, where none of them do.
|
|
1121
|
-
//
|
|
1122
|
-
// One stat per recorded snapshot, once per cycle, hoisted out of the
|
|
1123
|
-
// per-file path exactly like checksumsByCollection. The syscalls are
|
|
1124
|
-
// ~2.2ms per 1842 paths and cost the same whether the files are there
|
|
1125
|
-
// or not. At the build level it does not register: warm rebuilds of
|
|
1126
|
-
// the 10k perf corpus ran 3.36s median with this against 3.56s
|
|
1127
|
-
// without, i.e. nominally faster, which is only to say the difference
|
|
1128
|
-
// is well inside the run-to-run spread (3.0-4.1 vs 3.5-4.7 over five
|
|
1129
|
-
// runs each). Re-measure with `npm run test:perf` before trusting a
|
|
1130
|
-
// claim that it got slower.
|
|
1131
|
-
//
|
|
1132
|
-
// Resolves destinations through resolveOutputPath, which is what
|
|
1133
|
-
// auditOutput uses. The two must agree — `--audit-output` reporting
|
|
1134
|
-
// files that the build just called unchanged is the symptom, not a
|
|
1135
|
-
// separate diagnostic.
|
|
1136
|
-
// Memoized for the cycle. Two callers ask — the source checksum gate
|
|
1137
|
-
// once per scan, recordedHashes() once per layouts dispatch — and both
|
|
1138
|
-
// ask before anything has rendered, so one walk answers both. Cleared
|
|
1139
|
-
// at the end of onFinalize, which is where this cycle's renders are
|
|
1140
|
-
// recorded and the answer stops being true.
|
|
1141
|
-
missingOutputIds() {
|
|
1142
|
-
if (cachedMissingOutputs) return cachedMissingOutputs
|
|
1143
|
-
const missing = new Set()
|
|
1144
|
-
for (const row of stmtSelectAll.iterate()) {
|
|
1145
|
-
const snap = rowToSnap(row)
|
|
1146
|
-
if (!snap.destination) continue
|
|
1147
|
-
if (!existsSync(resolveOutputPath(snap.destination))) missing.add(snap.id)
|
|
1148
|
-
}
|
|
1149
|
-
cachedMissingOutputs = missing
|
|
1150
|
-
return missing
|
|
1151
|
-
},
|
|
1152
|
-
|
|
1153
|
-
// Drops the memo. Called at the end of onFinalize; exported on the
|
|
1154
|
-
// surface so a test can force a re-read without a full cycle.
|
|
1155
|
-
forgetMissingOutputs() {
|
|
1156
|
-
cachedMissingOutputs = null
|
|
1157
|
-
},
|
|
1158
|
-
|
|
1159
1102
|
// Walk the output folder against recorded snapshots, returning
|
|
1160
1103
|
// a diff describing missing / mismatched / orphaned /
|
|
1161
1104
|
// unverifiable. Backs `mikser --audit-output`. Pure: no mutations.
|
|
@@ -1543,5 +1486,5 @@ onFinalize(async () => {
|
|
|
1543
1486
|
// Dropped here rather than at the start of the next cycle because in
|
|
1544
1487
|
// watch mode there may not be one for hours, and a stale set held that
|
|
1545
1488
|
// long would keep re-dispatching entities it saw as missing.
|
|
1546
|
-
|
|
1489
|
+
forgetMissingOutputs()
|
|
1547
1490
|
})
|
package/src/plugins/assets.js
CHANGED
|
@@ -6,9 +6,10 @@ import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
|
|
|
6
6
|
import { existsSync } from 'node:fs'
|
|
7
7
|
import { createRequire } from 'node:module'
|
|
8
8
|
import { globby } from 'globby'
|
|
9
|
+
import { isFullCycle, outputMissing } from '../invalidation.js'
|
|
9
10
|
import _ from 'lodash'
|
|
10
11
|
import map from 'p-map'
|
|
11
|
-
|
|
12
|
+
|
|
12
13
|
|
|
13
14
|
// Normalize a `options.presets[name]` value to a consistent
|
|
14
15
|
// { matches, options } shape so callers don't have to inspect which form
|
|
@@ -357,8 +358,10 @@ export function assets(options = {}) {
|
|
|
357
358
|
// the only thing that ever said a file was missing.
|
|
358
359
|
//
|
|
359
360
|
// Checked first and cheaply: one stat, and if the derivative is gone
|
|
360
|
-
// no amount of marker archaeology changes the answer.
|
|
361
|
-
|
|
361
|
+
// no amount of marker archaeology changes the answer. Asked of
|
|
362
|
+
// invalidation.js so a derivative and a rendered page agree on what
|
|
363
|
+
// "the output is gone" means.
|
|
364
|
+
if (outputMissing(entity.destination)) return false
|
|
362
365
|
let result = false
|
|
363
366
|
let revisions = []
|
|
364
367
|
const assetChecksum = `${entity.destination}.${entity.preset.checksum}.md5`
|
package/src/plugins/files.js
CHANGED
|
@@ -207,12 +207,9 @@ export function files(options = {}) {
|
|
|
207
207
|
// so the next one cannot be added to that place and missed here.
|
|
208
208
|
//
|
|
209
209
|
// priorChecksums is still bulk-prefetched per scan so the gate
|
|
210
|
-
// reads a map instead of doing per-file SQL
|
|
211
|
-
//
|
|
212
|
-
// latter for the cycle, so this shares the walk with useSource's
|
|
213
|
-
// own scans rather than paying for a second one.
|
|
210
|
+
// reads a map instead of doing per-file SQL. What overrides the
|
|
211
|
+
// checksum, the gate asks invalidation.js for itself.
|
|
214
212
|
const priorChecksums = checksumsByCollection(collection)
|
|
215
|
-
const missingOutputs = runtime.manifest?.missingOutputIds() ?? new Set()
|
|
216
213
|
const scanned = new Set()
|
|
217
214
|
await pMap(paths, async relativePath => {
|
|
218
215
|
const { source } = await ensureLink(relativePath)
|
|
@@ -230,7 +227,7 @@ export function files(options = {}) {
|
|
|
230
227
|
// the catalog already has this file unchanged and there is
|
|
231
228
|
// nothing to emit. Progress ticks either way — a gated file
|
|
232
229
|
// was still looked at.
|
|
233
|
-
const newChecksum = await gateChecksum(source, id, { priorChecksums
|
|
230
|
+
const newChecksum = await gateChecksum(source, id, { priorChecksums })
|
|
234
231
|
updateProgress()
|
|
235
232
|
if (newChecksum === null) return
|
|
236
233
|
await createEntity({
|
package/src/source.js
CHANGED
|
@@ -46,6 +46,7 @@ import { ACTION } from './constants.js'
|
|
|
46
46
|
import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils.js'
|
|
47
47
|
import { reportGated, reportChanged } from './report.js'
|
|
48
48
|
import { findById, findEntities, checksumsByCollection } from './catalog.js'
|
|
49
|
+
import { bypassReason } from './invalidation.js'
|
|
49
50
|
import { useDatabase } from './database/index.js'
|
|
50
51
|
|
|
51
52
|
// Per-source-scan registerFile concurrency. The work is dominated by
|
|
@@ -81,20 +82,12 @@ const SCAN_CONCURRENCY = 16
|
|
|
81
82
|
// disagree, and the losing combination (empty content + a checksum correct
|
|
82
83
|
// for the finished file) is permanent, because every later sync then
|
|
83
84
|
// short-circuits on "unchanged".
|
|
84
|
-
|
|
85
|
-
// wrote a file that is no longer on disk (manifest.missingOutputIds()). The
|
|
86
|
-
// gate answers "has this INPUT changed", and deleting the output folder does
|
|
87
|
-
// not change any input — so without this, `rm -rf out` followed by a build
|
|
88
|
-
// short-circuits every file here, emits nothing, renders nothing, and reports
|
|
89
|
-
// success over an empty folder. An entity whose output is gone is not
|
|
90
|
-
// unchanged in any sense the caller means.
|
|
91
|
-
export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes, missingOutputs } = {}) {
|
|
85
|
+
export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
|
|
92
86
|
const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (canGate) {
|
|
87
|
+
// What overrides the checksum is not this function's to decide — see
|
|
88
|
+
// invalidation.js. It used to be, and the copy in files.js then missed
|
|
89
|
+
// the output-existence clause when that was added here.
|
|
90
|
+
if (!bypassReason({ reload, id })) {
|
|
98
91
|
const priorChecksum = priorChecksums
|
|
99
92
|
? priorChecksums.get(id)
|
|
100
93
|
: findById(id)?.checksum
|
|
@@ -430,12 +423,6 @@ export function useSource(core, options) {
|
|
|
430
423
|
// per-file instead of doing per-file SQL lookups. ~14× faster
|
|
431
424
|
// at 14k entities (column projection vs full-entity JSON.parse).
|
|
432
425
|
const priorChecksums = checksumsByCollection(collection)
|
|
433
|
-
// Prefetched once per scan, beside priorChecksums and for the same
|
|
434
|
-
// reason: the gate needs an O(1) answer per file, and this is one
|
|
435
|
-
// pass over the snapshots rather than a stat inside the per-file
|
|
436
|
-
// path. Entities listed here defeat the gate — their input is
|
|
437
|
-
// unchanged but the file they produced is gone.
|
|
438
|
-
const missingOutputs = runtime.manifest?.missingOutputIds() ?? new Set()
|
|
439
426
|
// Parallel register — file read + checksum is I/O-bound. Set
|
|
440
427
|
// additions, scanStats increments, and journal addEntry calls
|
|
441
428
|
// are all single-threaded-JS-atomic so no locking required;
|
|
@@ -443,7 +430,7 @@ export function useSource(core, options) {
|
|
|
443
430
|
// and INSERTs are ~10μs vs ~ms-per-file-read, so the journal
|
|
444
431
|
// is never the bottleneck.
|
|
445
432
|
await pMap(files, async (file) => {
|
|
446
|
-
await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums
|
|
433
|
+
await registerFile(file, { logger, scanned, stats: scanStats, priorChecksums })
|
|
447
434
|
if (phase === 'import') updateProgress()
|
|
448
435
|
}, { concurrency: SCAN_CONCURRENCY })
|
|
449
436
|
|
|
@@ -464,7 +451,7 @@ export function useSource(core, options) {
|
|
|
464
451
|
logger.info(scanSummary({ cap, loaded: files.length, ...scanStats }))
|
|
465
452
|
})
|
|
466
453
|
|
|
467
|
-
async function registerFile(file, { logger, action = ACTION.CREATE, scanned, priorChecksums
|
|
454
|
+
async function registerFile(file, { logger, action = ACTION.CREATE, scanned, priorChecksums } = {}) {
|
|
468
455
|
// stats — when called from the scanHook the outer scan provides
|
|
469
456
|
// a Map to accumulate counts. For onSync (chokidar single-file
|
|
470
457
|
// events) the counter is absent and per-file tally is not
|
|
@@ -497,7 +484,7 @@ export function useSource(core, options) {
|
|
|
497
484
|
}
|
|
498
485
|
}
|
|
499
486
|
|
|
500
|
-
const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes
|
|
487
|
+
const chksum = await gateChecksum(file, id, { reload, priorChecksums, bytes })
|
|
501
488
|
if (chksum === null) {
|
|
502
489
|
if (stats) stats.skipped++
|
|
503
490
|
// Never becomes a render task, so it would otherwise be invisible
|
package/src/utils.js
CHANGED
|
@@ -1187,29 +1187,6 @@ export async function writeOutput(file, bytes) {
|
|
|
1187
1187
|
return true
|
|
1188
1188
|
}
|
|
1189
1189
|
|
|
1190
|
-
// Did this cycle evaluate the whole corpus, or only what changed?
|
|
1191
|
-
//
|
|
1192
|
-
// The distinction is what makes a "declared X matched nothing" warning
|
|
1193
|
-
// worth printing. On an incremental cycle only changed entities are
|
|
1194
|
-
// re-evaluated, so a pattern or preset legitimately matches nothing in a
|
|
1195
|
-
// run of two — printing it there means the warning fires on every healthy
|
|
1196
|
-
// build, and a warning that always fires is one people filter out, taking
|
|
1197
|
-
// the real instance with it.
|
|
1198
|
-
//
|
|
1199
|
-
// True for: --force, a first-run or wiped database, and a cache
|
|
1200
|
-
// invalidation. Those are exactly the cases where the import gate is
|
|
1201
|
-
// bypassed and every entity is presented for evaluation.
|
|
1202
|
-
// Takes the runtime rather than reading the singleton, so a plugin passes
|
|
1203
|
-
// the one it was injected with — which is the singleton in a real build and
|
|
1204
|
-
// the harness's stand-in under test.
|
|
1205
|
-
export function isFullCycle(rt = runtime) {
|
|
1206
|
-
return !!(
|
|
1207
|
-
rt?.options?.force
|
|
1208
|
-
|| rt?.options?.firstRun
|
|
1209
|
-
|| rt?.catalog?.cacheInvalidated
|
|
1210
|
-
)
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
1190
|
// ── operating-system and file-manager litter ────────────────────────────
|
|
1214
1191
|
//
|
|
1215
1192
|
// Exposing a source folder over a network filesystem (mikser-io-drive) or
|